L’assistant de l’editeur — la quatrieme face du plugin
Neuvieme notebook de la serie « AI Engine par son API ». Les precedents ont ouvert les faces une a une : l’administrateur pilote le plugin par mwai/v1 (grain 1), l’agent dialogue avec WordPress devenu serveur MCP par mcp/v1 (grain 4), le visiteur anonyme converse par mwai-ui/v1 au nonce de session (grain 6). Il manque la face de celui qui ecrit : l’editeur — l’auteur dans son ecran de redaction, qui veut une IA a cote du texte, pas dans un onglet a cote.
Cette face existe, et le sondage qui a ouvert ce grain l’a mesuree : la route mwai-ui/v1/editor/submit repond sur la version gratuite du plugin, avec une completion reelle, un compte de tokens, et une signature tres parlante — un champ actionsvide. C’est la frontiere entre l’assistant gratuit (du chat dans l’editeur) et le Copilot payant (des actions sur les blocs), lisible directement dans la reponse de l’API. Ce notebook traverse la face de bout en bout : ouvrir la route, mesurer sa frontiere d’acces, decouvrir son contrat par les refus, puis mesurer ce que l’assistant fait — et ne fait pas. Chaque marche du notebook est une mesure : frontieres d’acces, contrat, prix en tokens, memoire, routage, rotation du nonce.
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) ;
le notebook cree ou retrouve un utilisateur de test editor (consent.editor, pose par le notebook precedent, idempotent) ;
aucune donnee reelle : corpus 100 % synthetique.
Contrairement au grain precedent (OAuth, protocole pur), ce notebook appelle reellement le modele : chaque completion part du serveur vers l’environnement declare, et le usage de la reponse nous rend visible ce que ce passage coute en tokens. Prevoir de la patience : chaque completion d’un modele a raisonnement prend plusieurs dizaines de secondes, et le notebook en enchaine une dizaine.
# Configuration et helpers. Aucune cle n'est stockee ici : tout vient# de instance-jetable/.env (README, etape 5).import base64import jsonimport osimport refrom 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("/")ADMIN_USER = os.getenv("VALMONT_ADMIN_USER", "")APP_PASSWORD = os.getenv("VALMONT_APP_PASSWORD", "")print("Base URL :", BASE_URL)creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()ENTETES = {"Authorization": "Basic "+ creds, "Content-Type": "application/json"}EDITEUR_MDP = os.getenv("VALMONT_EDITOR_PASSWORD") # fixture consent.editor, posee par le notebook OAuth (meme .env)def 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 editor_submit(session, nonce, payload, timeout=300):"""POST /mwai-ui/v1/editor/submit au X-WP-Nonce donne.""" r = session.post(BASE_URL +"/wp-json/mwai-ui/v1/editor/submit", headers={"X-WP-Nonce": nonce,"Content-Type": "application/json"}, json=payload, timeout=timeout)try:return r.status_code, r.json()exceptValueError:return r.status_code, {"_brut": r.text[:200]}# Utilisateur editor de test : retrouve s'il existe, cree sinon (idempotent).r = requests.get(BASE_URL +"/wp-json/wp/v2/users?search=consent&per_page=20", headers=ENTETES, timeout=60)existant = [u for u in r.json() if u.get("name") =="consent.editor"]if existant:print("utilisateur editor existant :", existant[0]["id"])else: r = requests.post(BASE_URL +"/wp-json/wp/v2/users", headers=ENTETES, json={"username": "consent.editor", "password": EDITEUR_MDP,"email": "consent.editor@example.test","name": "consent.editor", "roles": ["editor"]}, timeout=60)print("creation editor :", r.status_code)session_editor = requests.Session()print("login editor :", "OK (302)"if login_wordpress(session_editor, "consent.editor", EDITEUR_MDP) else"ECHEC")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093
utilisateur editor existant : 2
login editor : OK (302)
1. Ou vit la route
Tout commence par la carte : le namespace mwai-ui/v1 — le meme que celui de la face visiteur — declare ses routes. C’est un point de structure qui surprend : les deux faces les plus eloignees en droits (le visiteur anonyme et l’editeur connecte) partagent le meme namespace, et la separation ne se lit pas dans l’URL. Elle se lit dans la permission_callback de chaque route — invisible tant qu’on ne la provoque pas. C’est exactement ce que la section suivante mesure.
# La carte du namespace partage visiteur/editeur.ui = requests.get(BASE_URL +"/wp-json/mwai-ui/v1", timeout=30).json()print("routes declarees dans mwai-ui/v1 :")for chemin, spec insorted(ui.get("routes", {}).items()):print(" ", chemin, spec.get("methods"))print()print("args declares par /editor/submit :", ui["routes"]["/mwai-ui/v1/editor/submit"]["endpoints"][0]["args"])
Deux familles vivent ici : les files/* (televersement, liste, suppression — la memoire de fichiers du plugin) et les deux submit : chats/submit pour le chatbot public, editor/submit pour l’editeur. Et le detail qui compte pour la suite : la declaration de /editor/submit n’expose aucun arg — pas de schema, pas de contrat visible. Une API peut etre ouverte et muette ; quand le schema ne dit rien, c’est le refus du serveur qui documente. La route dit aussi, par contraste, ou est la frontiere des roles : partagee avec le visiteur par l’URL, separee de lui par le droit.
2. La frontiere : deux nonces, un header
Essayons la route avec le nonce de la face visiteur — celui que start_session delivre a quiconque. La session editor est deja connectee (cookies valides) : si le nonce etait neant, la reponse passerait. Voyons ce que le serveur en dit.
# Mesure 1 : le nonce visiteur contre la route editeur.r = session_editor.post(BASE_URL +"/wp-json/mwai/v1/start_session", timeout=30)nonce_visiteur = r.json().get("restNonce")print("nonce visiteur (start_session) :", nonce_visiteur)statut, reponse = editor_submit(session_editor, nonce_visiteur, {"newMessage": "Bonjour"})print("editor/submit au nonce visiteur :", statut)print("code :", reponse.get("code"))print("message :", reponse.get("message"))
403, rest_cookie_invalid_nonce. Le refus est precis : ce n’est pas l’identite qui manque (la session editor est valide), c’est le nonce — plus exactement son action. Un nonce WordPress n’est pas un jeton universel : il est emis pour un contexte nomme (wp_create_nonce('wp_rest') pour l’API REST). Le nonce de start_session est emis pour le contexte du chatbot visiteur ; l’editeur exige celui du contexte wp_rest. Deux nonces, deux actions, un meme header X-WP-Nonce — et c’est l’appariement nonce/action/cookie qui ouvre la porte, pas la seule possession d’un nonce. C’est la defense en profondeur de WordPress : le cookie prouve l’identite, le nonce prouve l’intention, et aucun des deux ne remplace l’autre. La lecon du grain 6 se complete : un nonce n’est pas une authentification, et il n’est meme pas transportable entre contexts.
3. Le bon nonce : ou il vit, comment il tourne
Le nonce wp_rest d’une session connectee n’a pas d’endpoint dedie — il est embarque dans les pages d’administration. Le plugin y injecte sa configuration (systeme, environnements, limites) avec un champ rest_nonce. C’est le mecanisme standard de WordPress (le wpApiSettings des ecrans admin), repris par AI Engine pour sa propre configuration. L’editeur qui charge son ecran d’administration recupere ainsi, en meme temps que la page, sa cle d’API.
# Extraction du nonce de session depuis la page wp-admin.NONCE = nonce_de_wpadmin(session_editor)print("nonce extrait de wp-admin :", NONCE)print("longueur :", len(NONCE) if NONCE else0, "caracteres")
nonce extrait de wp-admin : 4eda12041a
longueur : 10 caracteres
Dix caracteres hexadecimaux — le format nonce de WordPress, court par design : un nonce n’a pas vocation a etre fort face au brute force (son espace est petit), il a vocation a etre frais et lie — lie a l’utilisateur, a la session, et a l’action. Sa duree de vie est de 12 a 24 heures ; et le code du plugin prevoyait sa propre rotation : quand une reponse part avec un nonce qui commence a vieillir, elle peut embarquer un champ new_token — le remplacement. Nous verrons a la fin si nos reponses le portent (spoiler : avec un nonce frais, la mesure dit que non — et c’est la bonne reponse).
4. Le contrat decouvert par le refus
Revenons a la declaration muette : aucun arg declare. Le contrat de la route — quels champs, quels noms — ne se lit ni dans le schema ni dans la documentation de l’API. Il se mesure. Premier essai avec l’intuition naturelle : un champ message.
# Mesure 2 : le champ intuitif 'message' contre le contrat reel.statut, reponse = editor_submit(session_editor, NONCE, {"message": "Bonjour"})print("editor/submit avec 'message' :", statut)print("message d'erreur :", reponse.get("message"))
editor/submit avec 'message' : 400
message d'erreur : Empty message.
400, « Empty message. » — le refus le plus utile qui soit : la route a bien recu quelque chose, l’a trouve vide, et son message nomme presque le champ attendu. Le contrat reel est newMessage — et il s’accompagne d’options decouvertes de la meme facon : instructions, messages, envId, model, chatId. Cette maniere d’apprendre un contrat par les refus successifs est la competence centrale de la serie : sur une API sans schema, chaque erreur bien lue est une ligne de documentation ecrite par le serveur lui-meme. La methode tient en une regle : changer UNE chose a la fois, lire le message, re-essayer — jamais deux champs a la fois.
5. La completion reelle — et son prix visible
Le bon champ pose, la route parle enfin : completion reelle, reply du modele, et surtout le bloc usage — le compte exact de ce que ce passage a coute. C’est la que la face editeur se distingue du chat visiteur du grain 6 : meme header, meme famille de nonce, mais un compte de tokens rendu a l’appelant.
# Mesure 3 : la completion reelle au bon contrat.statut, reponse = editor_submit(session_editor, NONCE, {"newMessage": "Reponds en une phrase : quelle est la capitale de la France ?"})print("statut :", statut)print("cles de la reponse :", sorted(reponse.keys()))print("reply :", reponse.get("reply"))print("usage :", reponse.get("usage"))print("actions:", reponse.get("actions"))
statut : 200
cles de la reponse : ['actions', 'feedbackId', 'reply', 'success', 'usage']
reply : La capitale de la France est Paris.
usage : {'prompt_tokens': 45, 'completion_tokens': 10, 'total_tokens': 55, 'price': None, 'queries': 1, 'accuracy': 'estimated'}
actions: []
Trois choses dans cette reponse. La completion elle-meme, banale. Le usage : la completion affiche 10 tokens pour sept mots rendus — ici le cout suit la phrase visible au mot pres. Ce compteur n’a pas toujours cette tete : branche sur un modele a raisonnement (les grains precedents de la serie l’ont mesure sur d’autres routes), le meme usage enfle de tout le raisonnement depense avant la phrase — la reponse visible n’en est que la pointe emergee. L’assistant de l’editeur expose cette depense telle quelle — information precieuse pour qui s’interroge sur le cout d’une aide a l’ecriture interpolee a chaque frappe. Et enfin actions: [] — le champ vide qui dit tout : le Copilot payant retourne des actions sur les blocs Gutenberg (creer, modifier, reecrire un bloc) ; l’assistant gratuit ne retourne que du texte. La frontiere commerciale du plugin est inscrite dans la reponse de sa propre API — et le champ accuracy: estimated du usage rappelle au passage que le compte rendu est une estimation du plugin, pas une facture du fournisseur.
6. Ce que instructions change vraiment
Le contrat offrait un champ instructions — distinct du message. C’est la distinction system-prompt/message de la serie (grain 2) : posons une contrainte forte en instructions, rien dans le message, et mesurons si la contrainte descend jusqu’a la reponse.
# Mesure 4 : A/B sur le champ instructions.statut_sans, rep_sans = editor_submit(session_editor, NONCE, {"newMessage": "Nomme un animal."})statut_avec, rep_avec = editor_submit(session_editor, NONCE, {"newMessage": "Nomme un animal.","instructions": "Reponds en exactement trois mots, sans ponctuation."})mots_sans =len((rep_sans.get("reply") or"").split())mots_avec =len((rep_avec.get("reply") or"").split())print("sans instructions :", statut_sans, "| reply :", rep_sans.get("reply"), "|", mots_sans, "mots")print("avec instructions :", statut_avec, "| reply :", rep_avec.get("reply"), "|", mots_avec, "mots")
sans instructions : 200 | reply : L'éléphant. | 1 mots
avec instructions : 200 | reply : Lion. | 1 mots
La comparaison ci-dessus dit l’essentiel : le champ instructions est ecoute — le meme message y change de reponse (« L’éléphant. » sans consigne, « Lion. » avec). Mais la consigne de forme « exactement trois mots » n’est pas tenue par ce moteur local : les deux replies font un mot. La tenue litterale d’une contrainte de forme est une capacite du moteur, pas de la route — la consigne descend bien jusqu’a la reponse, son execution reste a la hauteur du moteur branche. Pour le projet client, la distinction reste le nerf des agents d’ateliers (grain 2) : un persona est une instruction, et ce qui n’est pas dans les instructions n’est pas dans le comportement — ce qui y est ne s’execute qu’a hauteur du moteur. La face editeur retrouve la meme physique — la distinction ne depend pas de la surface (chatbot public ou assistant interne), mais du moteur commun.
7. La memoire : qui la porte ?
Le contrat a un champ messages — un tableau. La question : la route se souvient-elle des tours precedents, ou faut-il lui re-apporter l’historique a chaque appel ? Mesurons : un premier tour pose une information, un second — sans historique — la demande, puis un troisieme avec l’historique re-apporte.
# Mesure 5 : continuite — sans historique, puis avec.statut1, rep1 = editor_submit(session_editor, NONCE, {"newMessage": "Mon animal prefere est le lynx. Retiens-le pour la suite."})info_modele = rep1.get("reply")print("tour 1 :", statut1, "|", str(info_modele)[:60])statut2, rep2 = editor_submit(session_editor, NONCE, {"newMessage": "Quel est mon animal prefere ? Reponds juste le nom."})print("tour 2 (sans historique) :", statut2, "| reply :", str(rep2.get("reply"))[:80])statut3, rep3 = editor_submit(session_editor, NONCE, {"newMessage": "Quel est mon animal prefere ? Reponds juste le nom.","messages": [{"role": "user", "content": "Mon animal prefere est le lynx."}, {"role": "assistant", "content": str(info_modele)}]})print("tour 3 (avec historique) :", statut3, "| reply :", str(rep3.get("reply"))[:80])print("le lynx est dans le tour 2 :", "lynx"instr(rep2.get("reply")).lower(),"| dans le tour 3 :", "lynx"instr(rep3.get("reply")).lower())
tour 1 : 200 | Je comprends que vous avez mentionné un animal spécifique :
tour 2 (sans historique) : 200 | reply : Chat
tour 3 (avec historique) : 200 | reply : Le lynx.
le lynx est dans le tour 2 : False | dans le tour 3 : True
Lecture des trois lignes ci-dessus : le tour 2, isole, rend « Chat » — la route est stateless, chaque appel repart de zero — et le tour 3, qui re-apporte l’historique, rend « Le lynx. ». La memoire n’a jamais ete dans le serveur. La memoire de conversation n’est pas dans le serveur : elle est a la charge du client, qui renvoie le fil. C’est le contrat du grain 6 retrouve a l’identique cote editeur : le chatbot public portait son historique dans messages, l’assistant interne aussi. Consequence d’architecture : deux onglets ouverts sur le meme compte ne partagent rien, et le « contexte » d’une conversation n’existe que tant que quelqu’un le transporte. Pour l’integrer dans un editeur, c’est l’extension navigateur ou le plugin qui devient responsable du fil.
8. Le routage : envId et model sont-ils ecoutes ?
Dernier coin du contrat : envId et model. La serie a appris (grain 5) que la matrice d’usages route chaque usage vers un environnement ; l’editeur a son chatbot interne, avec son scope dedie. Les champs sont-ils decoratifs ou actifs ? Testons avec l’environnement reel de l’instance (lu dynamiquement — lecon du grain precedent : jamais d’identifiant en dur), puis avec un modele inconnu.
environnement reel de l'instance : llm-local
envId explicite : 200 | reply : OK.
model inconnu : 500 | message : The environment is required.
Les deux mesures tranchent : avec l’envIdcorrect lu dynamiquement, la completion passe — le champ est ecoute, l’appelant peut pointer l’assistant vers un environnement precise (et la reponse au premier appel de ce notebook, elle, s’est routee toute seule vers l’environnement par defaut). Avec un model inconnu — et sans envId — le serveur refuse avec « The environment is required. » : exactement l’erreur a froid du grain 21, ici declenchee par le client. Passer un modele sans son environnement fait tomber la resolution entiere — le routeur exige le couple, pas le nom, et on ne lui force pas la main en API. La face editeur herite donc de toute la discipline de routage de la serie, champ par champ.
9. La rotation du nonce, mesuree par son absence
Reste le new_token promis par la lecture du code : la reponse peut porter un nonce de remplacement quand le notre vieillit. Nos appels sont partis avec un nonce frais ; mesurons honnetement ce que nos reponses en disent.
# Mesure 6 : new_token dans nos reponses ?reponses = [reponse, rep_sans, rep_avec, rep1, rep2, rep3, rep_env]portent_new_token = [i +1for i, r inenumerate(reponses) if"new_token"in r]print("reponses examinees :", len(reponses))print("portent new_token :", portent_new_token or"aucune")
reponses examinees : 7
portent new_token : aucune
Aucune — et c’est la reponse attendue : le mecanisme de rotation ne s’active que sur un nonce en fin de vie (le code le declenche quand la verification signale l’age), et le notre est frais. La mesure par absence est une mesure : elle confirme que le champ n’est pas decoratif dans nos echanges mais conditionnel, et elle rappelle le protocole a celui qui scripte la face editeur sur la duree — surveiller new_token dans chaque reponse, et remplacer son nonce le jour ou il apparait, sous peine de voir ses appels successifs tomber un a un dans le rest_cookie_invalid_nonce de la section 2 — le mode d’echec le plus trompeur qui soit, car il arrive apres des heures de succes, sans changement de code.
10. Quatre faces, une synthese
La serie peut maintenant fermer le tour complet. Les quatre faces du plugin, leurs mecanismes d’acces, et ce qui les separe :
Face
Route
Mecanisme d’acces
Vu au
administrateur
mwai/v1
application password (identite pleine)
grain 1
agent
mcp/v1
bearer MCP, ou OAuth delegue (consent admin + PKCE)
grains 4 et 8
visiteur
mwai-ui/v1 chats/submit
nonce de start_session, sans identite
grain 6
editeur
mwai-ui/v1 editor/submit
cookies de session + nonce wp_rest
ce grain
Le tableau se lit en diagonale : aucune face ne partage son mecanisme avec sa voisine, et les deux qui partagent un namespace (visiteur, editeur) sont separees par la paire cookie+nonce — la mesure de la section 2. Le plugin applique partout le meme principe avec des cles differentes : la surface definie le droit, et le droit ne se devine pas — il se provoque, se mesure, et se lit dans le refus. C’est la competence que cette serie a voulu transmettre, du premier appel authentifie au consentement OAuth : savoir faire parler une API par ses frontieres.
Bilan
La quatrieme face est reelle et mesurable.editor/submit repond sur la version gratuite : completion, usage, et un champ actions vide qui inscrit la frontiere commerciale dans la reponse elle-meme.
Le contrat se decouvre par les refus. Aucun arg declare : le 400 « Empty message. » a nomme newMessage, et les mesures successives ont valide instructions, messages, envId, model.
La memoire est a charge du client. Route stateless : le tour isole oublie, le tour avec historique re-apporte souvient — meme contrat que la face visiteur.
Le nonce est lie a son action. Le nonce visiteur echoue contre la route editeur (403 mesure) : deux contextes, deux nonces, un header — et une rotation (new_token) a surveiller pour tout script durable.
Le routage de la serie s’applique.envId ecoute, model valide : la discipline modeles/environnements des grains 5 et 7 garde la main sur l’assistant interne.
Avec cette neuvieme note, le tour des faces est complet : admin, agent, visiteur, editeur — quatre surfaces, quatre mecanismes, un moteur. Les familles restantes du catalogue restent fermees sur la version gratuite (WooCommerce metier, statistiques, RAG par REST).
Exercices
Les trois exercices suivants sont a completer (remplacez pass). L’instance doit etre demarree, le .env charge, et les variables session_editor, NONCE et la fonction editor_submit issues des cellules precedentes.
# Exercice 1 — le prix du raisonnement.# Le usage de chaque reponse expose completion_tokens. Ecrire une# fonction prix_moyen(session, nonce, question, n=3) qui pose n fois# la meme question courte a l'assistant et retourne la MEDIANE des# completion_tokens (module statistics). Comparer la mediane au# nombre de mots de la reply : le rapport dit combien le modele# depense en raisonnement pour chaque mot rendu.def prix_moyen(session, nonce, question, n=3):pass
# Exercice 2 — construire le fil.# La route est stateless : l'historique voyage dans 'messages'.# Ecrire une fonction conversation(session, nonce, tours) qui prend# une liste de messages utilisateur, appelle l'assistant tour par# tour EN RE-APPORTEANT l'historique cumule (user + assistant a# chaque tour), et retourne la liste des replies. Verifier avec un# dernier tour "Resume notre echange en une phrase" que les tours# precedents sont bien dans le contexte.def conversation(session, nonce, tours):pass
# Exercice 3 — la survie d'un script long.# Un nonce wp_rest vit 12 a 24 h, et la reponse peut porter un# 'new_token' de remplacement. Ecrire une fonction tenace(session,# nonce, question) qui : (1) appelle l'assistant ; (2) si la# reponse porte 'new_token', remplace le nonce pour l'appel suivant ;# (3) si le statut est 403, re-extrait le nonce de wp-admin (fonction# nonce_de_wpadmin) et rejoue l'appel UNE fois. Retourner (statut,# reponse, nonce_final) — le squelette d'un client qui tient une# journee de travail.def tenace(session, nonce, question):pass