Configurer et interroger des chatbots — le chatbot est un document JSON

Deuxieme notebook de la serie « AI Engine par son API » (apres presenter-ai-engine-par-son-api). On passe du socle a une fonctionnalite : le chatbot. Dans AI Engine, un chatbot n’est pas un programme a ecrire : c’est un document JSON stocke dans la configuration du plugin. On le lit, on le duplique, on le modifie — tout passe par l’API REST mwai/v1.

Ce notebook cree un second chatbot (« comite », un comite de lecture) en dupliquant la configuration du premier, verifie que l’ecriture est persistee, puis pose la meme question aux deux personas pour mesurer ce que les instructions changent — et ce qu’elles ne changent pas.

Les sorties de ce notebook proviennent d’une execution reelle contre l’instance locale (voir « Provenance et limites », en fin de fichier).

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 :

Notebook Contenu
presenter-ai-engine-par-son-api instance, API, catalogue des chatbots, premiere completion
configurer-chatbots-par-l-api (ce notebook) lire, dupliquer, ecrire et interroger des chatbots
notebooks suivants RAG/embeddings, agents MCP, formulaires, WooCommerce

Trois niveaux de lecture, dans chaque notebook :

  1. Decouverte — ce que fait la fonctionnalite, vue par l’API ;
  2. Branchement — comment on l’a branchee dans le projet ;
  3. Exercice — reutiliser le pattern sur un cas voisin.
# Configuration et helpers. Aucune cle ni adresse de provider n'est stockee
# dans ce fichier : tout vient de instance-jetable/.env (README, etape 5).

import base64
import os
import re
from pathlib import Path

import requests
from dotenv import load_dotenv

# Localisation du .env : a cote du notebook (instance-jetable/.env),
# sinon dans le repertoire courant.
charges = []
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)


def api(route, method="GET", payload=None):
    """Appel REST WordPress. route est relative, ex. '/mwai/v1/ai/completions'."""
    url = BASE_URL + "/wp-json" + route
    entetes = {"Content-Type": "application/json"}
    if ADMIN_USER and APP_PASSWORD:
        creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
        entetes["Authorization"] = "Basic " + creds
    reponse = requests.request(method, url, headers=entetes, json=payload, timeout=120)
    reponse.raise_for_status()
    return reponse.json()


# Normalisation des sorties du modele : les LLM locaux emettent parfois des
# emojis ou du markdown malgre les consignes. On normalise l'affichage par du
# code (jamais de sortie retouchee a la main).
_EMOJIS = re.compile(
    "[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U0001F300-\U0001F5FF"
    "\U0001F680-\U0001F6FF\U0001F900-\U0001F9FF\U00002B00-\U00002BFF"
    "\U0000FE00-\U0000FE0F]+",
    re.UNICODE,
)

def clean_text(texte):
    """Retire emojis, symboles markdown et espacements superflus."""
    if not isinstance(texte, str):
        return texte
    texte = _EMOJIS.sub(" ", texte)
    texte = re.sub(r"[*_#>`~]+", " ", texte)
    return re.sub(r"\s+", " ", texte).strip()


def extrait(texte, n=220):
    """Extrait court et propre d'une reponse, pour un affichage lisible."""
    propre = clean_text(texte)
    if len(propre) <= n:
        return propre
    return propre[:n] + " [...]"
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093

Lire un chatbot : un document JSON, deux endpoints

Deux routes de la famille settings se repondent :

Route Verbe Effet
/mwai/v1/settings/chatbots GET lire la liste complete des chatbots
/mwai/v1/settings/chatbots POST remplacer toute la liste par celle envoyee

La lecture renvoie un document JSON par chatbot : identite (botId, name, aiName), comportement (instructions, temperature, maxTokens, model), presentation (startSentence, themeId, icon…) — 54 champs sur cette instance. Les champs sensibles ne sont jamais affiches : la table ci-dessous est une allowlist stricte, et le secret (apiKey) est vide sur les chatbots de cette instance — la cle vit au niveau de l’environnement, pas du chatbot.

# 1. Lire la liste des chatbots : un document JSON par chatbot
bots = api("/mwai/v1/settings/chatbots")["chatbots"]
print("Chatbots configures :", [b["botId"] for b in bots])
print("Champs par document :", len(bots[0]) if bots else 0)

valmont = next(b for b in bots if b["botId"] == "valmont")
print()
print(f"Les {len(valmont)} cles du document « valmont » :")
print(", ".join(sorted(valmont)))

# Table curatee : allowlist stricte, aucun champ sensible n'est affiche.
CHAMPS_AFFICHES = ("botId", "name", "aiName", "model", "temperature", "maxTokens", "startSentence")
for bot in bots:
    print()
    for champ in CHAMPS_AFFICHES:
        print(f"  {champ:15s} : {bot.get(champ)}")
Chatbots configures : ['default', 'valmont', 'comite']
Champs par document : 54

Les 54 cles du document « valmont » :
aiName, apiKey, botId, centerOpen, containerType, contentAware, copyButton, crossSite, embeddingsEnvId, fileUpload, fileUploads, footerType, fullscreen, functions, guestName, headerSubtitle, headerType, historyStrategy, icon, iconAlt, iconBubble, iconPosition, iconText, iconTextDelay, imageUpload, inputType, instructions, localMemory, maxMessages, maxResults, maxTokens, maxUploads, mcpServers, messagesType, mode, model, multiUpload, name, openDelay, pdfButton, scope, startSentence, temperature, textClear, textCompliance, textInputMaxLength, textInputPlaceholder, textSend, themeId, userName, voice, width, window, windowAnimation

  botId           : default
  name            : Default
  aiName          : AI: 
  model           : gpt-5.5
  temperature     : 0.8
  maxTokens       : 4096
  startSentence   : Hi! How can I help you?

  botId           : valmont
  name            : Valmont
  aiName          : Valmont: 
  model           : qwen3.6-35b-a3b
  temperature     : 0.6
  maxTokens       : 1024
  startSentence   : Bonjour, bienvenue a la Maison Valmont. Comment puis-je vous renseigner ?

  botId           : comite
  name            : Comite de lecture
  aiName          : Comite: 
  model           : qwen3.6-35b-a3b
  temperature     : 0.6
  maxTokens       : 1024
  startSentence   : Bonjour. Envoyez-moi un extrait de manuscrit, je le soumets a la grille de lecture.

Lecture du resultat : trois documents, et pas une ligne de code

La liste renvoie trois botId – default, valmont, comite – chacun decrit par 54 champs. C’est le point central de la serie : ce qui distingue un chatbot d’un autre n’est pas du code, c’est le contenu de ces champs. Le comparatif le montre directement : default tourne sur gpt-5.5 avec temperature 0.8 et maxTokens 4096, tandis que valmont et comite partagent qwen3.6-35b-a3b, temperature 0.6 et maxTokens 1024. Changer de persona, ici, revient a editer un document.

Deux champs parmi les 54 meritent d’etre remarques des maintenant, parce qu’ils reviendront :

  • instructions porte le prompt systeme – c’est lui qui fabrique la personnalite, et c’est le seul champ que l’etape suivante modifiera.
  • apiKey est un champ du document, donc renvoye par l’API au meme titre que les autres. C’est la raison de l’allowlist d’affichage employee ci-dessus : on choisit les cles a imprimer, plutot que de faire confiance au serveur pour ne rien renvoyer de sensible.

Seul startSentence est visible du visiteur ; tout le reste du document est de la configuration invisible.

Ecrire : le POST remplace TOUTE la liste (pattern read-modify-write)

POST /settings/chatbots n’accepte pas « creez-moi un bot » : il attend la liste complete des chatbots, et remplace l’ancienne par la nouvelle. Pour creer ou modifier un chatbot, il faut donc :

  1. lire la liste (GET) ;
  2. modifier une copie en memoire — ici : dupliquer la configuration de valmont, puis changer l’identite, la phrase d’accueil et les instructions ;
  3. ecrire la liste complete (POST).

C’est le pattern read-modify-write. En remplacant systematiquement par botId (ni ajouter a la suite, ni dedoubler), l’operation devient idempotente : rejouer la cellule ne cree pas de doublon.

# 2. Creer le chatbot « comite » par duplication de valmont (upsert)
def copier_configuration(source, bot_id, name, ai_name, instructions, start_sentence):
    """Duplique la config de source ; change l'identite et les instructions."""
    config = dict(source)
    config["botId"] = bot_id
    config["name"] = name
    config["aiName"] = ai_name
    config["startSentence"] = start_sentence
    config["instructions"] = instructions
    return config

INSTRUCTIONS_COMITE = (
    "Vous etes membre du comite de lecture de la Maison Valmont, maison "
    "d'edition de romans feminins. On vous soumet un extrait de manuscrit : "
    "repondez en l'evaluant en trois temps - qualites, defauts, "
    "recommandation (accepter, retravailler, refuser). Texte brut, francais, "
    "sans emoji ni markdown."
)
comite = copier_configuration(
    valmont, "comite", "Comite de lecture", "Comite: ",
    INSTRUCTIONS_COMITE,
    "Bonjour. Envoyez-moi un extrait de manuscrit, je le soumets a la grille de lecture.",
)

# Read-modify-write : on remplace par botId (idempotent, pas de doublon)
liste = [b for b in bots if b["botId"] != "comite"] + [comite]
resultat = api("/mwai/v1/settings/chatbots", method="POST", payload={"chatbots": liste})
print("Ecriture acceptee :", resultat.get("success"))

# Verification par une nouvelle lecture : l'ecriture est bien persistee
apres = api("/mwai/v1/settings/chatbots")["chatbots"]
print("Chatbots apres ecriture :", [b["botId"] for b in apres])
comite_ecrit = next(b for b in apres if b["botId"] == "comite")
print("instructions persistees :", comite_ecrit["instructions"] == INSTRUCTIONS_COMITE)
Ecriture acceptee : True
Chatbots apres ecriture : ['default', 'valmont', 'comite']
instructions persistees : True

Lecture du resultat : ce qui prouve l’ecriture, ce n’est pas le POST

Le POST repond Ecriture acceptee : True – mais cette valeur dit seulement que le serveur a accepte la charge utile, pas qu’il l’a conservee. La preuve tient dans les deux lignes suivantes, qui proviennent d’une relecture :

  • Chatbots apres ecriture : ['default', 'valmont', 'comite'] – toujours trois entrees. C’est le controle qui compte pour cet endpoint : puisque le POST remplace la liste entiere, un read-modify-write mal ecrit n’aurait pas leve d’erreur, il aurait simplement renvoye une liste tronquee au seul chatbot envoye. Retrouver default et valmont intacts est ce qui valide le pattern.
  • instructions persistees : True – le champ modifie a bien survecu a l’aller-retour.

C’est aussi ce qui rend la cellule idempotente : l’upsert cherche comite par son botId et le remplace s’il existe, au lieu d’ajouter une entree. La reexecuter dix fois laisse toujours trois chatbots – verifiable en la relancant.

Interroger : la meme question a deux personas

Deux chatbots existent maintenant cote a cote : valmont, le bibliothecaire de la maison, et comite, le comite de lecture. On leur pose exactement la meme question — un extrait de manuscrit depose — et on mesure ce qui sort : le texte (tronque pour l’affichage) et les tokens.

Ce que le projet Livres Agites appelle un « persona » est ici un champ instructions dans un document JSON. La question de ce notebook est donc simple : ce champ change-t-il vraiment la reponse ?

# 3. La meme question a valmont et a comite : texte et tokens
question = (
    "Un auteur vient de deposer ce debut de roman : « Marie remonta la rue "
    "enneigee en serrant le manuscrit sous son manteau. Elle savait que la "
    "decision du comite deciderait de sa carriere. » Que faites-vous de ce texte ?"
)

reponses = {}
for bot_id in ("valmont", "comite"):
    reponse = api("/mwai/v1/ai/completions", method="POST",
                  payload={"botId": bot_id, "message": question})
    reponses[bot_id] = reponse
    usage = reponse.get("usage") or {}
    print("===", bot_id, "===")
    print("reponse :", extrait(reponse.get("data"), 400))
    print("tokens  :", {k: usage[k] for k in ("prompt_tokens", "completion_tokens", "total_tokens") if k in usage})
    print()

# Mesure simple de la ressemblance : part des mots du debut de comite
# deja presents dans le debut de valmont (100 premiers mots de chaque).
debut_valmont = set(clean_text(reponses["valmont"].get("data")).split()[:100])
mots_comite = clean_text(reponses["comite"].get("data")).split()[:100]
partage = sum(1 for m in mots_comite if m in debut_valmont)
if mots_comite:
    pct = partage / len(mots_comite)
    print(f"Mots du debut de comite deja chez valmont : {partage}/{len(mots_comite)} ({pct:.0%})")
else:
    print("comite n'a rien repondu")
=== valmont ===
reponse : Ce début est fonctionnel et efficace . Il remplit les trois fonctions essentielles d'une première phrase : poser un décor, introduire un personnage, et lancer un enjeu. Voici une analyse détaillée : Ce qui fonctionne bien - Immédiateté narrative : Aucune exposition superflue. Le lecteur comprend la situation, les enjeux et le ton en deux phrases. - Économie des moyens : Le manuscrit serré sous le  [...]
tokens  : {'prompt_tokens': 66, 'completion_tokens': 1828, 'total_tokens': 1894}

=== comite ===
reponse : Ce début est efficace et fonctionnel . Il remplit son rôle principal : installer un personnage, un enjeu et une tension narrative dès la première phrase. Voici une analyse détaillée, structurée comme le ferait un éditeur ou un correcteur professionnel : Points forts 1. Immédiateté et enjeux clairs : En deux phrases, on sait qui est Marie, ce qu'elle porte, où elle va, et pourquoi c'est crucial. Le [...]
tokens  : {'prompt_tokens': 66, 'completion_tokens': 2001, 'total_tokens': 2067}

Mots du debut de comite deja chez valmont : 49/100 (49%)

Ce que dit la mesure : les instructions orientent, elles ne garantissent pas

Comparez les deux extraits ci-dessus. Ce qu’on observe sur cette instance, execution apres execution : les deux personas repondent par des evaluations du texte — meme nature de reponse, meme outillage linguistique — et la mesure de vocabulaire partage le chiffre (elle varie a chaque execution, le LLM est non deterministe). Le persona change l’angle, la structure, le ton : des differences de degre, pas de nature. Ce n’est pas une demonstration preparee — rejouez la cellule, vous obtiendrez une autre paire de reponses, et le profil restera.

Trois lecons, tirees du chantier Livres Agites :

  1. Le persona est une ergonomie, pas une frontiere. Changer instructions change le contrat de dialogue, pas les capacites du modele. Si la difference compte (moderation, filtrage, role metier), il faut la verifier par la mesure, jamais la supposer — c’est la lecon des grains 7-8 de cadrer-les-agents.md.
  2. maxTokens du document n’est pas une borne stricte. Mesure sur cette instance : maxTokens a 96 dans le JSON du chatbot, le modele a produit 3775 tokens. Passe au niveau de la requete (champ maxTokens du POST /ai/completions), le plafond est respecte — mais la reponse peut alors etre vide : un modele a raisonnement consomme d’abord son budget de reflexion, et il ne reste rien pour le texte visible.
  3. La config se lit et s’ecrit ; le comportement se mesure. C’est la separation qui structure toute la serie : le document JSON est deterministe (GET, POST, verification par relecture), la reponse du modele ne l’est pas.

Ce qu’on en a fait dans le projet Livres Agites

Dans le projet d’origine, AI Engine porte six chatbots : Laura (accueil, documentee par RAG), les quatre agents d’ateliers d’ecriture, et le bot par defaut. Chacun est un document de configuration de la forme vue ici — certains avec contentAware (RAG) et des fonctions MCP attachees, d’autres en simple dialogue. Le pattern de ce notebook — lire, dupliquer, adapter, verifier — est exactement celui utilise pour deriver les personas les uns des autres (voir livresagites-parcours.md, parcours 0 : module Client).

Exercices

Trois exercices, du plus simple au plus integre. Les fonctions sont a completer ; api(), clean_text() et copier_configuration() sont disponibles. Chaque exercice se verifie d’une ligne de test.

Exercice 1 — lire la configuration d’un chatbot

Completez lire_configuration(bot_id) : elle retourne le document JSON du chatbot demande, ou None s’il n’existe pas.

def lire_configuration(bot_id):
    """Retourne le document JSON du chatbot `bot_id`, None s'il est absent."""
    # A COMPLETER : GET /mwai/v1/settings/chatbots puis chercher par botId
    return None

Exercice 2 — un upsert idempotent

Completez upsert_chatbot(config) : elle remplace (ou ajoute) le chatbot config dans la liste par botId — pattern read-modify-write — puis retourne la liste des botId apres ecriture.

def upsert_chatbot(config):
    """Upsert de `config` par botId ; retourne la liste des botId apres ecriture."""
    # A COMPLETER : lire la liste, remplacer par botId, POST la liste complete, relire
    return []

Exercice 3 — dupliquer un persona

Completez dupliquer_persona(source_bot_id, nouveau_bot_id, instructions) : elle duplique la configuration de source_bot_id, change le botId et les instructions, persiste le nouveau chatbot et retourne son document. Si la source n’existe pas, retourner None.

def dupliquer_persona(source_bot_id, nouveau_bot_id, instructions):
    """Cree un nouveau chatbot par duplication de `source_bot_id`."""
    # A COMPLETER : lire_configuration + copier_configuration + upsert_chatbot
    return None

Provenance et limites

  • Instance testee : http://localhost:8093, montee via instance-jetable/docker-compose.jetable.example.yml, AI Engine 3.7.0 (version gratuite, wordpress.org), corpus synthetique « Maison Valmont ».
  • Provider : LLM local compatible OpenAI — l’adresse et la cle restent dans .env, jamais dans ce notebook.
  • Sorties commitees : executions reelles contre l’instance. Les reponses d’un LLM sont non deterministes : votre execution peut differer — c’est normal, et c’est le sujet de ce notebook.
  • Endpoints verifies ici (firsthand) : GET et POST /mwai/v1/settings/chatbots, POST /mwai/v1/ai/completions.
  • Mesures citees : difference partielle des personas — structure de reponse distincte mais vocabulaire en partie partage (la mesure precise, dans le notebook, varie a chaque execution) ; maxTokens de config non borne a l’execution (96 -> 3775 tokens) ; maxTokens de requete borne mais reponse vide possible (128 tokens, 0 caractere visible) — modele a raisonnement.
  • Redaction : la table de lecture est une allowlist stricte ; apiKey et les champs d’environnement ne sont jamais affiches.
  • Frontieres : pas de donnees client, pas de secret, pas d’IP de provider — regles du chantier CoursIA.
Retour au sommet