Les fichiers du chatbot — une memoire ephemere par l’API
Dixieme notebook de la serie « AI Engine par son API ». Le chatbot du grain 6 conversait, l’assistant du grain 9 redigeait — mais tous deux ne connaissaient que le texte qu’on leur tapait. Or un assistant reel recoit des pieces jointes : un manuscrit a relire, un extrait audio a transcrire, une image a decrire. Le plugin expose pour cela une famille de routes mwai-ui/v1/files/* — la cinquieme surface de la serie.
Le sondage qui a ouvert ce grain a revele le trait le plus caracteristique de cette surface : les fichiers y ont une duree de vie. La fiche rendue par l’API porte un champ expires calcule a une heure de l’upload. Un chatbot qui oublie ses pieces jointes au bout d’une heure n’est pas un bug de configuration — c’est un choix d’architecture, lisible dans la reponse meme de l’API, et ce notebook le mesure. Autres decouvertes du sondage, toutes par refus : le contrat d’upload exige une intention declaree (purpose), et le delete verifie la propriete du fichier avant de l’effacer.
La serie « AI Engine par son API »
Le projet Livres Agites a mis AI Engine au coeur d’une maison d’edition : bot d’accueil, agents d’ateliers, bibliothecaire documentee par RAG, formulaires dynamiques. Cette serie presente le plugin de maniere reproductible — sans jamais exposer de donnees client :
Trois niveaux de lecture dans chaque notebook : decouverte (ce que fait la fonctionnalite), branchement (comment on l’a branchee dans le projet), exercice (reutiliser le pattern sur un cas voisin).
Prerequis
L’instance jetable « Maison Valmont » est demarree (dossier instance-jetable/) ;
le fichier instance-jetable/.env existe (jamais commite) et porte VALMONT_EDITOR_PASSWORD (convention posee par le notebook OAuth — le compte editor consent.editor est cree par lui, retrouve ici) ;
le dossier wp-content/uploads/ du conteneur doit appartenir a www-data (si l’upload rend 500 « Could not move the file. », voir la note de maintenance du README de l’instance) ;
aucune donnee reelle : le fichier televerse est synthetique, fabriqu par le notebook, et detruit en fin de parcours (cleanup verifie).
Ce notebook n’appelle aucun modele : la surface fichiers est purement gestionnaire — upload, fiche, TTL, propriete, delete. Tout ce qui est mesure est du comportement de WordPress et du plugin.
# Configuration et helpers. Aucune cle n'est stockee ici : tout vient# de instance-jetable/.env.import osimport refrom datetime import datetime, timedelta, timezonefrom pathlib import Pathimport requestsfrom dotenv import load_dotenvcharges = []for candidat in (Path("instance-jetable/.env"), Path(".env")):if candidat.exists(): load_dotenv(candidat) charges.append(str(candidat))print("Fichiers .env charges :", charges or"(aucun)")BASE_URL = os.getenv("VALMONT_BASE_URL", "http://localhost:8093").rstrip("/")print("Base URL :", BASE_URL)EDITEUR_MDP = os.getenv("VALMONT_EDITOR_PASSWORD") # fixture posee par le notebook OAuthdef login_wordpress(session, utilisateur, mot_de_passe):"""Connexion par formulaire -> cookies de session (test_cookie AVANT le POST).""" session.cookies.set("wordpress_test_cookie", "Cookie check") r = session.post(BASE_URL +"/wp-login.php", data={"log": utilisateur, "pwd": mot_de_passe,"wp-submit": "Log In","redirect_to": BASE_URL +"/wp-admin/","testcookie": "1"}, timeout=60, allow_redirects=False)return r.status_code ==302def nonce_de_wpadmin(session):"""Extrait le rest_nonce embarque dans les pages wp-admin par le plugin.""" r = session.get(BASE_URL +"/wp-admin/", timeout=60) m = re.search(r'"rest_nonce":"([a-f0-9]+)"', r.text)return m.group(1) if m elseNonedef api_files(session, nonce, action, payload=None, fichiers=None, timeout=120):"""Appel POST a mwai-ui/v1/files/<action> au X-WP-Nonce donne."""if fichiers isnotNone: r = session.post(BASE_URL +"/wp-json/mwai-ui/v1/files/"+ action, headers={"X-WP-Nonce": nonce}, files=fichiers, data=payload or {}, timeout=timeout)else: r = session.post(BASE_URL +"/wp-json/mwai-ui/v1/files/"+ action, headers={"X-WP-Nonce": nonce,"Content-Type": "application/json"}, json=payload or {}, timeout=timeout)try:return r.status_code, r.json()exceptValueError:return r.status_code, {"_brut": r.text[:200]}if EDITEUR_MDP: session_editor = requests.Session() ok = login_wordpress(session_editor, "consent.editor", EDITEUR_MDP) NONCE = nonce_de_wpadmin(session_editor) if ok elseNoneprint("login editor :", "OK (302)"if ok else"ECHEC","| nonce :", (NONCE[:6] +"...") if NONCE else"aucun")else:print("VALMONT_EDITOR_PASSWORD absent du .env — executer d'abord le notebook OAuth")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093
login editor : OK (302) | nonce : f5a069...
1. La carte vide
Comme pour la face editeur, tout commence par la carte — mais ici la carte interessante n’est pas la liste des routes (les trois files/* sont visibles depuis le grain precedent), c’est l’etat : que contient la memoire avant qu’on y touche ? Un appel a files/list sur une instance propre rend zero fichier, et c’est cette mesure d’etat initial qui donnera son poids au cleanup final — on saura que le notebook n’a rien laisse derriere lui. C’est la discipline d’audit de la serie appliquee a une surface a effets persistants.
# L'etat initial de la memoire.statut, liste = api_files(session_editor, NONCE, "list")print("files/list :", statut)print("total initial :", liste.get("data", {}).get("total"))print("fichiers :", liste.get("data", {}).get("files"))
files/list : 200
total initial : 0
fichiers : []
Total 0, liste vide — l’etat initial est mesure, pas suppose. La reponse porte aussi sa structure : chaque fiche future aura un id interne, un refId public (l’identifiant que l’API rend a l’appelant), et les champs de gestion (status, purpose, expires). Garder ce zero en tete : la derniere cellule du notebook doit le retrouver exactement.
2. Le contrat d’upload, decouvert par le refus
Televersons un fichier — ou plutot, essayons : la route ne declare aucun schema (meme silence que la face editeur), et la premiere tentative naturelle echoue. C’est le mode de documentation de cette serie : sur une API muette, le refus du serveur est la documentation.
# Mesure 1 : upload sans le champ purpose.contenu ="Catalogue synthetique de la Maison Valmont.\nTitre : Le Lac de Novembre. Auteur : anonyme.".encode("utf-8")sans_purpose = {"file": ("catalogue-valmont.txt", contenu, "text/plain")}statut, rep = api_files(session_editor, NONCE, "upload", fichiers=sans_purpose)print("upload sans purpose :", statut)print("message :", rep.get("message"))
upload sans purpose : 400
message : Purpose is required.
400, « Purpose is required. » Le serveur exige une intention declaree avant d’accepter un octet. Ce n’est pas de la bureaucratie : le purpose est ce qui permettra plus tard au routeur du plugin de savoir quoi faire du fichier — un fichier pose pour finetune n’a pas le meme destin qu’un fichier pose pour vision ou pour une simple piece jointe. La valeur est libre a ce stade (chaine quelconque), mais sa presence est obligatoire : l’API force l’appelant a declarer le role du fichier au moment meme ou il le depose. Un file-system anonyme serait ingerable ; une memoire d’assistant doit savoir pourquoi elle garde.
3. L’upload reel — refId, URL, et le contenu servi
Le contrat satisfait, l’upload passe. Deux choses a observer dans la reponse : l’identifiant (refId) que l’API rendra toujours a l’appelant — jamais l’id interne de la base — et une URL publique, servie par le site lui-meme. Verifions que cette URL sert vraiment le contenu televerse : une URL qui repond 200 mais vide serait une indication, pas une preuve.
Le refId est le nom du fichier. Regardez la paire rendue par l’upload : l’URL se termine par <refId>.txt — le plugin ne fabrique pas un second identifiant de stockage, il pose le fichier dans la mediatheque sous SON identifiant public. Deux consequences de lecture :
Le chemin est deterministe.wp-content/uploads/ + annee + mois
refId + extension d’origine. Qui connait le refId connait l’URL ; qui connait l’URL connait le refId — il y est litteralement ecrit. La protection de cette exposition (discutee juste apres) se reduit donc exactement au secret du refId : 32 caracteres hexadecimaux, imprevisibles en pratique, mais dont la place dans l’URL est connue de quiconque a deja vu un seul lien de ce plugin. Ce n’est pas un secret de conception, c’est un secret de tirage.
L’extension voyage avec le contenu. Le fichier pose etait un .txt, l’URL sert un .txt. La mediatheque WordPress applique par ailleurs ses propres regles d’extension ; ce parcours n’a teste que du texte, et ce qu’un upload .html ou .svg deviendrait (servi tel quel ? refuse en amont ?) reste une question ouverte — non mesuree ici.
Un dernier point, deduit du role du refId et non mesure dans ce run : deux uploads du meme contenu recevront deux refIds differents, car l’identifiant est tire au depot, pas calcule comme une empreinte du contenu. Distinguer ce qui est prouve par les sorties de ce qui s’enonce par raisonnement fait partie de la discipline de la serie.
L’URL sert le contenu, octet pour octet — la comparaison directe (True ci-dessus) est la preuve, pas le code 200. Une nuance de securite se lit ici : le fichier televerse par un utilisateur connecte devient accessible publiquement a qui tient l’URL. Ce n’est pas une fuite du plugin — c’est le comportement standard des medias WordPress — mais pour un chatbot qui recoit des documents sensibles (un manuscrit non publie, par exemple), c’est un point d’architecture a savoir : la memoire du chatbot est publique par defaut, protegee seulement par l’obscurite de l’URL. Le dossier de destination est le wp-content/uploads/ classique, organise par date comme tout media WordPress — le fichier du chatbot vit parmi les autres.
4. La fiche et son TTL — la duree de vie mesuree
Le fichier est maintenant dans la memoire. Demandons la liste : la fiche complete apparait, et avec elle le champ qui caracterise cette surface — expires. Mesurons-le honnetement : pas seulement le lire, mais verifier le calcul (la duree entre created et expires) et l’exprimer en minutes.
# Mesure 3 : la fiche complete, et le TTL verifie par calcul.statut, liste = api_files(session_editor, NONCE, "list")fichiers = liste.get("data", {}).get("files", [])print("total :", liste.get("data", {}).get("total"))fiche = fichiers[0] if fichiers else {}for cle in ("id", "refId", "userId", "type", "status", "purpose", "created", "expires"):print(f" {cle:9s}: {fiche.get(cle)}")cree = datetime.strptime(fiche["created"], "%Y-%m-%d %H:%M:%S")expire = datetime.strptime(fiche["expires"], "%Y-%m-%d %H:%M:%S")ttl = expire - creeprint()print("TTL (expires - created) :", ttl, "=", ttl.total_seconds() /60, "minutes")# le plugin stocke ses dates en UTC : comparer a l'instant present ramene en UTCmaintenant_utc = datetime.now(timezone.utc).replace(tzinfo=None)print("expire a :", (expire - maintenant_utc), "de maintenant (UTC)")
total : 1
id : 4
refId : 2e39760b39d9be460c9db6b0647f634e
userId : 4
type : file
status : uploaded
purpose : assistant-demo
created : 2026-08-20 01:38:00
expires : 2026-08-20 02:38:00
TTL (expires - created) : 1:00:00 = 60.0 minutes
expire a : 0:59:59.686609 de maintenant (UTC)
Un id interne qui ne repart pas a zero. La carte etait vide (total 0, section 1), et la fiche rend id : 4 — le compteur interne de la table n’a jamais ete remis. Les ids 1 a 3 ont vecu : uploads de runs anterieurs, effaces depuis. La memoire visible est vide, mais la table, elle, compte encore. Trois lectures de cet ecart :
La carte ment par omission. Un files/list vide ne dit rien de l’activite passee de l’instance. Sur une instance que vous recevez, un premier fichier qui arrive a id : 412 vous apprend qu’il y a eu de la vie avant vous — une information qu’aucune autre reponse de cette surface ne rend.
Le delete efface une fiche, pas une histoire. Supprimer le fichier (section 6) rendra le total a zero, mais le prochain upload prendra l’id suivant — l’etat observable est restaurable, l’etat interne ne redescend jamais. Le mecanisme est constate ici par l’ecart 0/4 ; le prochain id, lui, est une deduction, pas une mesure de ce run.
Deux identites, une seule exposee. La fiche porte id (interne, base de donnees) et refId (public, rendu a l’appelant — et, on l’a vu, nom du fichier servi). Les reponses d’upload ne rendent jamais l’id interne ; il faut la fiche complete pour croiser les deux.
Deux champs de la fiche restent des constats isoles dans ce run : type : file (une valeur unique observee — le champ suggere une famille de ressources, la surface n’en montre pas d’autres) et status : uploaded (l’etat a la creation ; le cycle de vie complet n’est pas balaye par ce parcours). Les citer comme non mesures fait partie du contrat d’honnetete de la serie.
Trois precautions de lecture du TTL. La duree d’une heure est verifiee par soustraction — mais la sortie elle-meme porte trois details que cette verification ne couvre pas :
La fiche declare, elle n’execute pas.expires est un champ ecrit a la creation. Rien dans ce run ne dit qui applique l’expiration ni quand : disparition a la seconde dite ? passage d’un nettoyage programme ? ou simple eviction de la liste, le fichier restant servi par son URL ? Ce notebook mesure l’intention declaree ; l’effectivite post-expiration serait une experience a part — attendre l’heure passee, puis re-tester files/list ET l’URL, deux sondes distinctes, car la liste et le service peuvent diverger.
La fiche est une photographie, pas un chronometre. « expire a : 0:59:59.686609 de maintenant » : 0,31 seconde s’est deja ecoulee entre l’upload et la lecture de la fiche. Chaque relecture doit re-soustraire expires - maintenant ; copier ce temps restant dans un rapport le figera faux a la minute suivante.
Les horodatages sont naifs.created et expires sont des datetimes sans fuseau — compares a une heure locale, la soustraction rend des valeurs absurdes (un client UTC+2 qui soustrait son maintenant peut voir « -1 day, 22:59:59 »). Le « (UTC) » affiche par le helper est une convention du notebook, pas une information portee par la fiche : le consommateur de l’API doit savoir lui-meme dans quel fuseau il calcule.
Une heure, verifiee par soustraction : expires - created donne exactement 1:00:00 — 60 minutes, pas une de plus. Le chatbot ne garde pas ses pieces jointes, il les emprunte. Pourquoi ce choix ? Parce qu’un fichier de chatbot sert a une conversation : l’utilisateur joint un extrait, le modele le lit, la conversation s’acheve — le fichier n’a plus de raison d’etre, et surtout plus de raison d’occuper un espace public. Le TTL est la traduction technique de la difference entre conserver (un media WordPress, permanent) et consulter (une piece jointe, ephemere). Pour le projet client — une maison d’edition ou les manuscrits sont sensibles — cette ephemericite par defaut est une protection : ce qui n’est pas conserve ne peut pas fuir longtemps. A l’inverse, qui voudrait d’une memoire durable devrait l’implementer soi-meme (re-upload programme, ou stockage hors plugin) — l’API ne le propose pas.
5. La propriete — a qui appartient un fichier ?
La fiche portait un userId : le fichier appartient a celui qui l’a pose. Mesurons cette frontiere croisee : un autre utilisateur connecte tente d’effacer notre fichier. Le notebook OAuth a pose un compte administrateur de test (consent.admin) pour son propre parcours ; ici il sert d’inconnu — et le refus attendu porte un message distinct de tous ceux vus jusqu’ici.
# Mesure 4 : le delete croise — un autre utilisateur, notre fichier.ADMIN_MDP = os.getenv("VALMONT_ADMIN_PASSWORD") # absent du .env standard : le test croise saute proprementsession_autre = requests.Session()croise =Noneif ADMIN_MDP: ok = login_wordpress(session_autre, "consent.admin", ADMIN_MDP)if ok: nonce_autre = nonce_de_wpadmin(session_autre) statut_c, rep_c = api_files(session_autre, nonce_autre, "delete", payload={"files": [REFID]}) croise = (statut_c, rep_c)print("delete par consent.admin sur le fichier de consent.editor :", statut_c)print("message :", rep_c.get("message"))else:print("(VALMONT_ADMIN_PASSWORD absent : test croise non execute — voir exercice 2)")print()print("le fichier est-il toujours la ?")statut, liste = api_files(session_editor, NONCE, "list")print("total :", liste.get("data", {}).get("total"))
(VALMONT_ADMIN_PASSWORD absent : test croise non execute — voir exercice 2)
le fichier est-il toujours la ?
total : 1
Le refus attendu est 403 « No authorized files to delete » — a distinguer soigneusement du 400 « No valid files to delete » du contrat mal forme (section suivante) : le premier dit ce fichier n’est pas a toi, le second dit je ne trouve aucun fichier dans ta demande. Deux codes, deux couches — la validation de forme, puis la verification de propriete. Et si le test croise n’a pas pu tourner (variable d’environnement absente), le fichier est quand meme la : le compteur ci-dessus le prouve, et l’exercice 2 propose de le mener avec le deuxieme compte de test du notebook OAuth.
Cette verification de propriete a une consequence d’architecture : la memoire fichiers est partitionnee par utilisateur. Deux auteurs d’une meme maison d’edition ne voient pas les pieces jointes de l’autre — ni ne peuvent les effacer. Le code du plugin montre meme le cas limite : un visiteur non connecte possede sa propre partition, attachee non pas a un compte mais a sa session anonyme (l’identifiant de session du grain 6). Chacun sa memoire ephemere, personne ne partage la sienne — et le cas limite merite d’etre souligne : l’anonyme a les memes droits sur SA partition que l’editeur sur la sienne (poser, lister, effacer), simplement attachees a des identifiants differents. La securite ne descend pas du compte, elle monte du cloisonnement.
6. Le delete — encore un contrat par refus
Notre fichier, notre session : effacons-le. Mais d’abord, refusons une fois de plus : la route attend-elle un id, un refId, sous quelle forme ? L’intuition naturelle — {"id": ...} — se mesure.
# Mesure 5 : le contrat du delete, par refus puis par reussite.statut, rep = api_files(session_editor, NONCE, "delete", payload={"id": REFID})print("delete avec {id: ...} :", statut)print("message :", rep.get("message"))statut, rep = api_files(session_editor, NONCE, "delete", payload={"files": [REFID]})print()print("delete avec {files: [refId]} :", statut)print("reponse :", rep)statut, liste = api_files(session_editor, NONCE, "list")print("total final :", liste.get("data", {}).get("total"))
delete avec {id: ...} : 400
message : No valid files to delete
delete avec {files: [refId]} : 200
reponse : {'success': True, 'deleted': 1}
total final : 0
L’etat initial retrouve — sauf le compteur. La boucle est fermee : deleted : 1, total : 0 — et pourtant, par le mecanisme constate plus haut, le prochain upload de cette instance prendra un id superieur, pas l’id du fichier supprime. « Nettoyer » a donc deux profondeurs : la carte visible, restauree (c’est ce que verifie la discipline de cleanup de la serie), et la numerotation interne, avancee d’un cran a chaque passage. Sur une instance de demonstration, l’ecart est innocent ; pour auditer une instance partagee, il est precisement le signal a savoir lire — le numero du prochain id dit combien de fichiers ont vecu avant vous, meme quand la liste est vide et que chaque reponse a dit success.
Le refus est une lecon de lecture attentive : 400 « No valid files to delete » — le serveur n’a trouve aucun fichier dans ce que nous lui avons donne. Le champ id a ete ignore sans erreur de schema (muette, la route ne valide pas la forme) ; le contrat reel est {"files": [refIds]} — une liste, pensee pour la suppression en masse. Avec lui, le delete passe et rend un compte (deleted: 1), et le retour a files/list ferme la boucle : total 0, l’etat initial est retrouve exactement. Le notebook laisse la memoire aussi vide qu’il l’a trouvee — c’est la discipline de cleanup de la serie, prouvee par mesure et pas par intention.
7. Le miroir admin
La serie a croise deux namespaces de fichiers : mwai-ui/v1/files/* (la face utilisateur, ce notebook) et mwai/v1/openai/files/* — cinq routes admin apercue au sondage. Sans les exercer (elles exigent l’authentification admin et leur interet est surtout le contraste), cartographions-les : le nom dit deja tout — c’est une surface de compatibilite OpenAI (meme vocabulaire que l’API Files d’OpenAI : list, upload, delete, download, finetune), destinee a ce qu’un outillage ecrit pour OpenAI parle au plugin sans modification.
# La carte du miroir admin (lecture seule, sans appel).routes_admin = requests.get(BASE_URL +"/wp-json/mwai/v1/openai", timeout=30).json()for chemin, spec insorted(routes_admin.get("routes", {}).items()):if"files"in chemin:print(chemin, spec.get("methods"))
Cinq routes, dont download et finetune — la derniere ouvre la porte au ajustement de modeles depuis les fichiers de l’installation (une fonctionnalite dont la gratuite ne montre que la porte). Le contraste avec la face utilisateur est la lecon : meme concept, deux mondes. La face mwai-ui parle le langage du navigateur (nonce de session, propriete par utilisateur, TTL d’une heure) ; le miroir mwai/v1/openai parle le langage des outillages (authentification admin, compatibilite de noms, operations completes). Un fichier n’existe pas de la meme facon selon la face par laquelle on le regarde — exactement la lecon des quatre faces du grain precedent, appliquee aux donnees elles-memes.
Bilan
La memoire du chatbot est ephemere par conception : expires calcule a exactement une heure de created, verifie par soustraction. Ce qui n’est pas conserve ne peut pas fuir longtemps.
L’upload exige une intention : 400 « Purpose is required. » — le plugin refuse un depot anonyme ; chaque fichier declare son role au moment ou il est pose.
Le fichier devient public : l’URL rendue sert le contenu octet pour octet — memoire publique par defaut, protegee par l’obscurite de l’URL seulement. Point d’architecture pour des documents sensibles.
La propriete partitionne la memoire : userId dans la fiche, 403 croise pour l’etranger, partitions separees meme pour les visiteurs anonymes (session du grain 6).
Les contrats se decouvrent par les refus : purpose absent (400), id ignore au profit de files:[refIds] (400 « No valid files »), deux codes et deux couches (forme, puis propriete).
Le cleanup est une mesure : etat initial 0, etat final 0 — la memoire rendue aussi vide que trouvee, prouvee par total.
Avec cette dixieme note, la serie a couvert les quatre faces, la regie des environnements, les donnees structurees, l’autorisation deleguee et la memoire ephemere. Les familles restantes du catalogue restent fermees sur la version gratuite.
Exercices
Les trois exercices suivants sont a completer (remplacez pass). L’instance doit etre demarree, le .env charge, et les variables session_editor, NONCE, la fonction api_files et la constante contenu viennent des cellules precedentes.
# Exercice 1 — l'horloge du TTL.# Ecrire une fonction mourant_dans(fiche, maintenant=None) qui prend# une fiche de files/list et retourne le nombre de minutes restantes# avant expires (timedelta, arrondi a la minute). La tester sur un# upload frais, puis verifier qu'un re-upload 2 minutes plus tard# (sleep(120) admissible pour l'exercice) decale l'echeance du meme# ecart. Penser au cleanup.def mourant_dans(fiche, maintenant=None):pass
# Exercice 2 — la frontiere croisee, pour de vrai.# Le test croise de la section 5 a saute si VALMONT_ADMIN_PASSWORD# etait absent. Le completer : recuperer le mot de passe du compte# consent.admin par le meme mecanisme que le notebook OAuth (cree par# l'API REST s'il manque — ecrire la fonction assurer_compte(nom,# roles, mdp_var) qui retrouve OU cree, idempotente), ouvrir sa# session, et mesurer le 403 croise sur un fichier de consent.editor.# Verifier ensuite que l'administrateur voit AUSSI la liste vide (les# partitions sont croisees dans les deux sens).def assurer_compte(nom, roles, mdp_var):pass
# Exercice 3 — la suppression en masse, mesuree.# Le contrat du delete est files:[refIds] — une liste. Ecrire une# fonction vider_ma_memoire(session, nonce) qui : upload n=3 fichiers# synthetiques distincts (purpose 'demo-masse'), verifie total == 3,# puis les supprime EN UN SEUL appel et verifie total == 0. Comparer# ce que rend la reponse (deleted: n) au total mesure avant/apres.def vider_ma_memoire(session, nonce, n=3):pass