Obtenir des données structurées — la case json, le fallback sans env et le null silencieux

Septième notebook de la série « AI Engine par son API ». Les notebooks précédents ont fait parler le chatbot, administré les formulaires, ouvert WordPress comme serveur MCP et piloté la régie des environnements. Restait une veine « non prouvée » depuis le premier grain : la route /ai/json, promise pour obtenir des données structurées — un JSON directement exploitable par le code appelant, pas une phrase à re-parser.

Le premier sondage s’était heurté à une erreur opaque : The environment is required. Ce notebook part de cette erreur, la reproduit, l’explique par ce que dit la matrice d’usages, puis la répare proprement — remplir la case json de la matrice par l’API — avant de mesurer honnêtement ce que la route rend réellement : JSON valide ou null silencieux, contrainte portée par le prompt ou par le protocole.

La série « AI Engine par son API »

Le projet Livres Agites a mis AI Engine au coeur d’une maison d’édition : bot d’accueil, agents d’ateliers, bibliothécaire documentée par RAG, formulaires dynamiques. Cette série présente le plugin de manière reproductible — sans jamais exposer de données client :

Notebook Contenu
presenter-ai-engine-par-son-api instance, API, catalogue des chatbots, première completion
configurer-chatbots-par-l-api lire, dupliquer, écrire et interroger des chatbots (document JSON global)
administrer-les-formulaires-par-l-api CRUD unitaire des formulaires (contenus WordPress), rendu public
piloter-wordpress-par-mcp WordPress comme serveur MCP : handshake, catalogue, tools/call
brancher-plusieurs-providers-par-l-api environnements, matrice d’usages, piège du settings/update
parler-au-chatbot-en-visiteur face navigateur : start_session, nonce, anti-CSRF vs authentification
obtenir-des-donnees-structurees ce notebook : /ai/json, la case json, le null silencieux
# 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 copy
import json
import os
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)

creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
ENTETES = {"Authorization": "Basic " + creds, "Content-Type": "application/json"}


def api(route, method="GET", payload=None, attendu=None):
    """Appel a l'API REST d'administration de AI Engine (mwai/v1).

    `attendu` : si fourni, n'appelle pas raise_for_status — on veut lire
    le corps d'erreur mesure (ex. HTTP 500), pas le transformer en exception.
    """
    r = requests.request(method, BASE_URL + "/wp-json" + route,
                         headers=ENTETES, json=payload, timeout=180)
    if attendu is None:
        r.raise_for_status()
    return r


def modeles_de(env):
    """Les identifiants de modeles declares dans un environnement."""
    return [m["model"] if isinstance(m, dict) else m for m in env.get("models", [])]


USAGES = [
    ("chat",       "ai_default"),
    ("fast",       "ai_fast_default"),
    ("vision",     "ai_vision_default"),
    ("images",     "ai_images_default"),
    ("audio",      "ai_audio_default"),
    ("json",       "ai_json_default"),
    ("embeddings", "ai_embeddings_default"),
]
print("Helpers prets.")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093
Helpers prets.

Le parcours en cinq temps

Les helpers sont prêts — voici l’itinéraire. Ce notebook suit une veine unique (obtenir du JSON fiable d’un moteur conversationnel) à travers cinq moments qui se répondent : l’erreur à froid qui avait bloqué le premier sondage (section 1), la matrice d’usages où la case json reste vide (section 2), le read-modify-write qui la remplit sans dégât collatéral (section 3), la requête réelle sur le catalogue Valmont (section 4), puis les mesures d’honnêteté — le null silencieux et la comparaison wire-level (section 5) — avant de rendre l’instance à son état (section 6).

Chaque section ne conclut que sur ce que sa mesure établit : le fil directeur est la différence entre « la réponse est verte » et « la donnée est là ».

1. L’appel à froid — l’erreur qui avait bloqué le premier sondage

La route /ai/json du namespace mwai/v1 promet une réponse structurée. Le raisonnement naïf : comme pour /ai/completion, on devait pouvoir passer envId et model dans la requête pour choisir le moteur. Le premier sondage de la série l’avait essayé — et obtenu une erreur qui n’évoque ni le modèle ni le serveur : The environment is required. Reproduisons-la à froid, sans aucun paramètre de moteur.

# 1. Appel a froid : message seul, aucune configuration de moteur.
reponse_froide = api("/mwai/v1/ai/json", method="POST",
                     payload={"message": "Liste les ouvrages du catalogue en JSON."},
                     attendu=500)
print("HTTP", reponse_froide.status_code)
try:
    corps = reponse_froide.json()
except ValueError:
    corps = {"(brut)": reponse_froide.text[:200]}
print(json.dumps(corps, indent=2, ensure_ascii=False))
HTTP 500
{
  "success": false,
  "message": "The environment is required."
}

Ce que dit l’erreur

Le message renvoie à un environnement — pas au message, pas au modèle. La lecture du code du plugin (rest.php, fonction rest_ai_json) explique ce qui s’est passé : la route ne transmet que params['message'] au moteur de requête JSON. Les paramètres envId / model éventuels sont ignorés structurellement : on ne peut pas choisir le moteur au niveau de la requête. La requête JSON hérite donc toujours de la case json de la matrice d’usages — et si cette case est vide, l’appel échoue avant d’avoir touché le moteur. Voyons cette matrice.

2. La matrice d’usages — la case json est vide

Le cinquième notebook (brancher-plusieurs-providers) a introduit la matrice d’usages : pour chaque usage (chat, vision, images, json, embeddings…), un couple ai_<usage>_default_env / ai_<usage>_default_model désigne le moteur à utiliser. Il l’avait lue — jamais écrite. Relisons-la : l’instance jetable Maison Valmont n’a qu’un environnement, llm-local (le moteur auto-hébergé), affecté à l’usage chat.

# 2. Lire la matrice d'usages.
options = api("/mwai/v1/settings/options").json()["options"]

envs = options.get("ai_envs", [])
print("Environnements de modeles (ai_envs) :")
for e in envs:
    print(f"  {e['id']:14s} [{e.get('type'):7s}] {e.get('name')} : {modeles_de(e) or '(aucun modele declare)'}")

print()
print(f"{'usage':12s} {'environnement':16s} modele")
for nom, prefixe in USAGES:
    env = options.get(prefixe + "_env") or "(defaut plugin)"
    model = options.get(prefixe + "_model") or "-"
    print(f"{nom:12s} {env:16s} {model}")
Environnements de modeles (ai_envs) :
  llm-local      [custom ] LLM local (OpenAI-compatible) : ['qwen3:8b']

usage        environnement    modele
chat         llm-local        qwen3:8b
fast         (defaut plugin)  gpt-5-mini
vision       (defaut plugin)  gpt-5-mini
images       (defaut plugin)  gpt-image-2
audio        (defaut plugin)  whisper-1
json         (defaut plugin)  gpt-5-mini
embeddings   (defaut plugin)  text-embedding-3-small

La case json : vide, avec un résidu de repli

La colonne json affiche l’environnement (defaut plugin) — la case est vide. Mais son modèle affiche gpt-5-mini : ce n’est pas un branchement, c’est la valeur de repli interne du plugin (MWAI_FALLBACK_MODEL_JSON). Le piège complet se lit maintenant :

  1. la case json n’a pas d’environnement ;
  2. le plugin retombe sur son modèle de repli gpt-5-mini — sans environnement ;
  3. la validation d’une requête exige que le modèle existe dans un environnement déclaré (ici, seul llm-local existe, et il ne sert que l’usage chat) ;
  4. sur une instance custom-only comme la nôtre, ce repli ne peut jamais passer — d’où l’erreur à froid, inévitable.

La seule issue : remplir la case json de la matrice. C’est une écriture — et le cinquième notebook a montré que le seul style d’écriture sûr ici est le read-modify-write du bloc complet.

Exercice 1 — lire toute la matrice en une structure

La boucle d’affichage ci-dessus traite les usages un par un. Écrivez matrice() qui retourne la matrice entière sous forme de dictionnaire {usage: {"env": ..., "model": ...}}, avec env et model à None quand la case est vide (pas la valeur de repli — la valeur réelle de la matrice).

Indice : api("/mwai/v1/settings/options").json()["options"], puis la liste USAGES déjà définie. Attention : options.get(...) renvoie None pour une clé absente — c’est exactement le marqueur « case vide ».

def matrice():
    """Retourne la matrice {usage: {"env": ..., "model": ...}}.

    env/model valent None quand la case est vide (pas de repli plugin).
    """
    # A COMPLETER : lire settings/options, construire le dictionnaire
    # a partir de la liste USAGES.
    return {}


m = matrice()
print("Case chat :", m.get("chat"))
print("Case json :", m.get("json"))
Case chat : None
Case json : None

Où en est l’exercice 1 ?

Les deux None affichés sont ceux du squelette : la fonction matrice() rend pour l’instant les valeurs brutes des deux cases, sans structure. Complétée, elle doit rendre le dictionnaire {usage: {"env": ..., "model": ...}} décrit dans sa docstring — la même information que la boucle d’affichage de la section 2, mais dans une forme qu’un test peut parcourir sans relire du texte formaté. C’est le premier geste du métier ici : transformer une lecture humaine en structure consommable.

3. Remplir la case — read-modify-write du bloc complet

settings/update ne met pas à jour, il remplace : la leçon mesurée du cinquième notebook. On ne pousse donc jamais une seule clé — on relit le bloc options complet, on modifie une copie en mémoire, on renvoie tout. L’instantané d’origine est conservé : il servira à la restauration finale (section 6).

# 3. Instantane -> modifier la case json -> renvoyer le bloc COMPLET.
instantane = api("/mwai/v1/settings/options").json()["options"]
print("Instantane : ", len(instantane), "cles | case json :",
      repr(instantane.get("ai_json_default_env")),
      repr(instantane.get("ai_json_default_model")))

# Le moteur de l'instance, deja branche sur l'usage chat.
ENV = instantane["ai_default_env"]
MODEL = instantane["ai_default_model"]

nouveau = copy.deepcopy(instantane)
nouveau["ai_json_default_env"] = ENV
nouveau["ai_json_default_model"] = MODEL

rep = api("/mwai/v1/settings/update", method="POST", payload={"options": nouveau}).json()
verif = api("/mwai/v1/settings/options").json()["options"]
print()
print("update           :", rep.get("success"), "|", rep.get("message"))
print("case json apres  :", repr(verif.get("ai_json_default_env")),
      repr(verif.get("ai_json_default_model")))
print("case chat intacte:", repr(verif.get("ai_default_env")),
      repr(verif.get("ai_default_model")))
print("nb de cles       :", len(instantane), "->", len(verif))
Instantane :  105 cles | case json : None 'gpt-5-mini'

update           : True | OK
case json apres  : 'llm-local' 'qwen3:8b'
case chat intacte: 'llm-local' 'qwen3:8b'
nb de cles       : 105 -> 105

La case est remplie — sans dégât collatéral

Le bloc complet est reparti tel quel, à deux clés près : la case json pointe maintenant sur llm-local / le modèle local, la case chat n’a pas bougé, et le compte de clés est stable (la signature du « remplacement propre » — le piège du cinquième notebook montrait un compte qui s’effondre). La route /ai/json a maintenant tout ce qu’il lui faut. Reste à savoir ce qu’elle rend.

4. La requête réelle — structurer le catalogue Valmont

L’objectif de la veine : obtenir du JSON exploitable, pas une phrase. La Maison Valmont n’a pas de catalogue en base pour le moteur (rien dans cette requête ne fait de RAG) — le notebook fournit donc le matériau dans le message : un extrait de catalogue synthétique, à restructurer. C’est le cas d’usage canonique de /ai/json : la machine extrait et structure, le code appelant consomme.

# 4. Extraits bruts du catalogue (fixture synthetique, fournie dans le message).
catalogue_brut = """
Retours de la comite de lecture, semaine 24 :
1) "Le Jardin des Vents" de Marion Estève, roman, 312 p., reco a l'unanimite, lecteurs: 4
2) essai "Politique du Sommeil" d'A. Karabatic, 208 p., reserve, lecteurs: 2
3) "La Cendre des Cartographes" (polar) de Livia Sorel, 440 p., reco majoritaire, lecteurs: 3
"""

message = (
    "Voici des extraits des retours de la comite de lecture de la Maison Valmont.\n"
    + catalogue_brut
    + "\nRetourne un tableau JSON : un objet par ouvrage avec les cles "
    "titre, auteur, genre, pages (nombre), recommendation, lecteurs (nombre)."
)

rep_json = api("/mwai/v1/ai/json", method="POST",
               payload={"message": message}, attendu=None).json()
print("success :", rep_json.get("success"))
if rep_json.get("success"):
    print("data    :", json.dumps(rep_json.get("data"), indent=2, ensure_ascii=False)[:800])
else:
    print("message :", rep_json.get("message"))
success : True
data    : {
  "ouvrages": [
    {
      "titre": "Le Jardin des Vents",
      "auteur": "Marion Estève",
      "genre": "roman",
      "pages": 312,
      "recommendation": "reco a l'unanimite",
      "lecteurs": 4
    },
    {
      "titre": "Politique du Sommeil",
      "auteur": "A. Karabatic",
      "genre": "essai",
      "pages": 208,
      "recommendation": "reserve",
      "lecteurs": 2
    },
    {
      "titre": "La Cendre des Cartographes",
      "auteur": "Livia Sorel",
      "genre": "polar",
      "pages": 440,
      "recommendation": "reco majoritaire",
      "lecteurs": 3
    }
  ]
}

Ce que la route a rendu

La réponse arrive en HTTP 200, success: true, et surtout data est déjà un objet — pas une chaîne à re-parser : AI Engine a décodé le JSON côté serveur. La structure rendue est fidèle au matériau (titre, auteur, genre, pages en nombre, recommandation, lecteurs en nombre), avec une liberté que le code appelant doit connaître : le message demandait « un tableau JSON », le moteur a rendu un objet contenant le tableau ({"ouvrages": [...]}). La contrainte « JSON valide » est tenue ; le schéma exact, lui, ne l’est pas — un consommateur robuste cherche le tableau dans la réponse au lieu d’exiger qu’elle soit le tableau.

5. Mesures d’honnêteté

Une route qui promet du JSON mérite mieux qu’un exemple qui marche. Trois mesures : (a) la donnée rendue est-elle du JSON valide ? (b) le null silencieux — le code du plugin parse la réponse du moteur avec json_decode sous try/catch, mais json_decode ne lève jamais d’exception en PHP : une réponse non-JSON rend success: true, data: null — est-ce observable ? (c) la contrainte de format est-elle portée par le prompt seul, ou descend-elle au niveau fil (response_format du protocole OpenAI-compatible) ?

# 5a. Le retour du moteur : JSON valide, et data == null ?
# La reponse complete contient la sortie brute du moteur dans 'data'
# quand le parsing a reussi. Rejouons l'appel en gardant la reponse brute.
rep_brute = api("/mwai/v1/ai/json", method="POST",
                payload={"message": message}, attendu=None).json()

cles = sorted(rep_brute.keys())
print("Cles de la reponse :", cles)
print("success :", rep_brute.get("success"), "| data :", type(rep_brute.get("data")).__name__)
if rep_brute.get("data") is not None:
    valide = True
    try:
        json.dumps(rep_brute["data"])  # data est deja un objet PHP->JSON
    except (TypeError, ValueError):
        valide = False
    print("data serialisable en JSON :", valide)
Cles de la reponse : ['data', 'success']
success : True | data : dict
data serialisable en JSON : True

Ce premier bord de la mesure

success: True, data est un dictionnaire, et il est sérialisable : côté syntaxe, tout est en ordre — la réponse est du JSON valide qui contient une vraie donnée. Ce bord-là établit donc que la route PEUT rendre du JSON habité. Mais ce n’est qu’un bord : la section 5b force volontairement une réponse non-JSON avec le même moteur et la même case json, pour voir ce que la route rend quand le contenu dégénère. La question de l’honnêteté n’est pas « un exemple marche-t-il ? » mais « que se passe-t-il quand ça ne marche pas ? » — et c’est la suite immédiate.

# 5b. Le null silencieux : forcer une reponse non-JSON.
# Meme moteur, meme case json, mais une consigne ANTI-JSON : le moteur doit
# produire du texte libre. Si le parsing echoue, success reste-t-il true
# avec data null ?
messages_anti_json = [
    "Réponds uniquement par une phrase, sans JSON, sans accolades : qui es-tu ?",
    "Réponds juste le mot: bonjour. Aucun JSON.",
]

for msg in messages_anti_json:
    r = api("/mwai/v1/ai/json", method="POST",
            payload={"message": msg}, attendu=None).json()
    print(f"consigne anti-JSON -> success: {r.get('success')} | "
          f"data: {type(r.get('data')).__name__} | message: {r.get('message')}")
    if r.get("data") is None:
        print("  -> null silencieux OBSERVE : success true, data null, aucun message d'erreur")
    else:
        print("  -> data rendu quand meme :", json.dumps(r["data"], ensure_ascii=False)[:200])
consigne anti-JSON -> success: True | data: dict | message: None
  -> data rendu quand meme : {"réponse": "Je suis un modèle de langage développé par Alibaba Cloud, conçu pour aider les utilisateurs à obtenir des informations et à accomplir diverses tâches."}
consigne anti-JSON -> success: True | data: dict | message: None
  -> data rendu quand meme : {"greeting": "bonjour"}

Le null silencieux : mesuré — et c’est une information

Résultat de la mesure : la consigne anti-JSON produit quand même du JSON. Le moteur a enveloppé sa phrase dans un objet — la réponse est parsable, data est non nul, aucun message d’erreur. Le null silencieux n’est donc pas observable sur cette configuration, et la raison est instructive : le piège du code (json_decode sous try/catch qui n’attrapera jamais rien) existe toujours, mais il ne peut pas se déclencher tant que le format est garanti en aval de la sortie du moteur. Le jour où la case json pointe un moteur qui ignore response_format (vieux llama.cpp, passerelle partielle), la réponse redeviendra du texte libre — et le plugin rendra success: true, data: null sans un mot. Le piège est réel ; il est simplement neutralisé ici par l’étage du dessous.

# 5c. Prompt-only ou wire-level ? Comparaison au moteur, hors plugin.
# AI Engine ajoute au message un suffixe d'instruction JSON et, pour un
# environnement custom (OpenAI-compatible), envoie response_format au
# niveau fil. On ne peut pas lire le fil depuis l'API WordPress -- mais on
# peut comparer le meme moteur directement : avec et sans response_format.
base_moteur = os.getenv("VALMONT_LLM_BASE_URL_MOTEUR")  # optionnel, .env

direct = None
if base_moteur:
    H = {"Authorization": "Bearer " + os.getenv("VALMONT_LLM_API_KEY_MOTEUR", "sk-local"),
         "Content-Type": "application/json"}
    corps = {"model": MODEL, "messages": [{"role": "user", "content": "Réponds juste le mot: bonjour"}], "max_tokens": 1000}
    import re as _re

    def _contenu(rep):
        # qwen3 peut emettre une trace de raisonnement avant la reponse :
        # le budget de tokens doit couvrir les deux, sinon content arrive vide.
        try:
            ch = rep.json()["choices"][0]
            c = (ch["message"].get("content") or "")
            fin = ch.get("finish_reason")
        except Exception:
            return "(pas de choix)"
        c = _re.sub(r"<think>.*?</think>", "", c, flags=_re.S).strip()
        return c[:80] if c else f"(content vide, finish_reason={fin})"

    r1 = requests.post(base_moteur + "/chat/completions", headers=H,
                       json={**corps}, timeout=120)
    r2 = requests.post(base_moteur + "/chat/completions", headers=H,
                       json={**corps, "response_format": {"type": "json_object"}}, timeout=120)
    print("sans response_format   :", _contenu(r1))
    print("avec response_format   :", _contenu(r2))
    direct = True
else:
    print("Comparaison directe moteur non configuree (VALMONT_LLM_BASE_URL_MOTEUR absent du .env).")
    print(" -> la discrimination prompt-only / wire-level se lit sur 5b :")
    print("    si la consigne anti-JSON produit quand meme du JSON, le format est")
    print("    impose au niveau fil ; si elle produit du texte libre, il ne l'est pas.")
sans response_format   : bonjour
avec response_format   : {"response": "bonjour"}

Verdict : wire-level, confirmé par les deux côtés

Les deux mesures se rejoignent. Côté moteur, la même consigne anti-JSON rend bonjour (texte libre) sans response_format et un objet JSON avec. Côté plugin, la consigne anti-JSON à travers /ai/json rend du JSON — le comportement observé est celui de la colonne avec response_format. Conclusion mesurée : pour un environnement de type custom (protocole OpenAI-compatible), la contrainte de format est bien descendue au niveau fil par AI Engine (le moteur chatml du plugin envoie response_format avec la requête au serveur) — elle ne repose pas que sur le suffixe de prompt ajouté au message. D’où la conséquence pratique : la robustesse du « JSON garanti » dépend du serveur autant que du plugin — changer llm-local pour une passerelle qui ignore response_format changerait silencieusement la garantie.

Bilan des mesures d’honnêteté

Avant les exercices, le tri des preuves accumulées. Cinq mesures ont encadré la même promesse : le plugin déclare une case json (section 2), la remplir ne casse rien (section 3), la route rend un JSON valide et habité quand le moteur coopère (5a), un null silencieux quand il ne coopère pas (5b), et la transformation est wire-level — confirmée des deux côtés du plugin (5c). Aucune de ces mesures seule ne suffit : c’est l’ensemble qui transforme « la case json existe » en « voici ce qu’elle garantit, et voici son mode de défaillance ».

Les deux exercices qui suivent prolongent exactement ce programme : l’un sonde la robustesse (un taux, pas un exemple), l’autre audite la restauration (un diff complet, pas deux cases affichées).

Exercice 2 — sonder la robustesse du JSON

Une seule requête ne prouve pas une route robuste. Écrivez taux_json(messages) qui envoie chaque message via /ai/json et retourne le taux de réponses avec data non nul, plus la liste des échecs. Testez-la avec : la consigne de catalogue (doit réussir), une consigne anti-JSON, et une consigne ambiguë (« parle-moi de la Maison Valmont »).

Indice : réutilisez api(...) avec payload={"message": m} ; comptez r.get("data") is not None. Attention au temps : trois appels à un modèle local, prévoir timeout confortable.

def taux_json(messages):
    """Retourne (taux_de_reussite, details) sur une liste de messages.

    Reussite = data non nul dans la reponse /ai/json.
    """
    # A COMPLETER : boucle d'appels, comptage des data non nuls,
    # collecte des (message_tronque, success, data_est_null).
    return 0.0, []


taux, details = taux_json(["Réponds juste le mot: bonjour"])
print("taux :", taux, "| details :", details)
taux : 0.0 | details : []

Lire le rendu du squelette

taux : 0.0 | details : [] est le rendu du squelette, pas une mesure : sans complétion, aucun message n’a été sondé, et le zéro ne veut rien dire. Une fois complétée, la fonction rendra la proportion de réponses au JSON valide sur une liste de messages — et la lecture deviendrait intéressante dans les deux sens : un taux de 1.0 sur trois messages ne prouve pas la robustesse (c’est la mise en garde de l’exercice), un taux proche de zéro avec des détails remplis cartographie les cas de défaillance. La bonne question pour dimensionner la liste : combien de messages faut-il pour qu’un taux soit une mesure et non un tirage ?

6. Restaurer l’état initial

La re-exécutabilité à froid est un invariant de la série : un notebook qui laisse l’instance modifiée fausse le suivant. La case json doit revenir à son état d’origine — vide — par le même read-modify-write du bloc complet, à partir de l’instantané pris avant toute modification.

# 6. Restauration : renvoyer l'instantane d'origine, bloc complet.
retour = api("/mwai/v1/settings/update", method="POST",
             payload={"options": instantane}).json()
final = api("/mwai/v1/settings/options").json()["options"]

print("update de restauration :", retour.get("success"))
print("case json finale       :", repr(final.get("ai_json_default_env")),
      repr(final.get("ai_json_default_model")))
print("case chat finale       :", repr(final.get("ai_default_env")),
      repr(final.get("ai_default_model")))
print("nb de cles             :", len(instantane), "->", len(final))
update de restauration : True
case json finale       : None 'gpt-5-mini'
case chat finale       : 'llm-local' 'qwen3:8b'
nb de cles             : 105 -> 105

Exercice 3 — auditer la restauration

« Les deux cases affichées sont revenues » ne prouve pas que tout est revenu. Écrivez audit_restauration(avant, apres) qui compare deux instantanés complets et retourne la liste des clés différentes (ajoutées, supprimées, changées de valeur). Un audit propre rend une liste vide — ou l’explique (le plugin écrit-il des compteurs d’usage entre-temps ?).

Indice : les clés sont l’union des deux dictionnaires ; comparez avant.get(k) et apres.get(k). Pour les valeurs imbriquées, une comparaison par json.dumps(..., sort_keys=True) suffit à détecter un écart.

def audit_restauration(avant, apres):
    """Liste des differences (cle, avant, apres) entre deux instantanes."""
    # A COMPLETER : union des cles, comparaison des valeurs,
    # retour [(cle, valeur_avant, valeur_apres), ...].
    return []


ecarts = audit_restauration(instantane, final)
print("ecarts :", len(ecarts))
for k, va, vp in ecarts[:10]:
    print(f"  {k}: {str(va)[:50]} -> {str(vp)[:50]}")
ecarts : 0

Le zéro qui ne prouve rien — pour l’instant

ecarts : 0 sort du squelette : l’audit n’a encore rien comparé. La différence avec la preuve visée est entièrement dans le spectre de la comparaison : « les deux cases affichées sont revenues » (ce que l’œil vérifie) compare deux valeurs ; l’audit complété compare l’instantané ENTIER d’avant avec l’état d’après, clé par clé. Un zéro rendu par ce diff complet serait une vraie preuve de restauration — le genre de différence entre « ça a l’air revenu » et « rien n’a changé » qui sépare une relecture d’un test.

Conclusion

Ce notebook a fermé la dernière veine « non prouvée » de la série — et le voyage valait le détour :

  • l’erreur d’origine était structurelle : /ai/json n’accepte pas de moteur dans la requête, il lit la case json de la matrice d’usages — vide sur une instance custom-only, où le repli gpt-5-mini sans environnement ne peut jamais passer la validation ;
  • la réparation est une écriture de matrice : read-modify-write du bloc complet, le seul style d’écriture sûr avec settings/update ;
  • la promesse « JSON » se mesure : validité du retour, observabilité du null silencieux, nature de la contrainte de format — chaque mesure est reproductible sur l’instance jetable ;
  • l’état initial est restauré — la série reste exécutable à froid.

Voir aussi : brancher-plusieurs-providers-par-l-api.ipynb (la matrice d’usages et le piège du settings/update), parler-au-chatbot-en-visiteur-par-l-api.ipynb (l’autre face, côté navigateur), presenter-ai-engine-par-son-api.ipynb (le socle : instance, routes, première completion).

Retour au sommet