Parcours QA — ce que l’API ne voit pas

Premier notebook du dossier 05-Playwright-AI-Engine/. Il ouvre une face du plugin que la serie « AI Engine par son API » ne pouvait pas atteindre.

Les six grains de cette serie ont mesure AI Engine par ses interfaces machine : l’API REST mwai/v1 en administrateur, le namespace mwai-ui/v1 en visiteur, le serveur MCP en agent. Toutes ces sondes partagent un angle mort : elles ne chargent jamais l’application React de l’administration. Or cette application n’est pas un simple afficheur. Elle ecrit.

La these de ce parcours : sur cette instance, une valeur ecrite par l’API REST peut etre defaite par le seul fait qu’un humain ouvre une page de reglages — sans cliquer sur quoi que ce soit. Et le resultat n’est pas stable : il depend d’une course entre deux requetes que le navigateur envoie coup sur coup. Une sonde qui echantillonne une fois ne mesure donc pas le phenomene, elle tire a pile ou face.

C’est la raison d’etre de ce dossier : le miroir de Playwright-OWUI/ cote AI Engine. Ce que seul un vrai navigateur peut constater.

Le dispositif

Tout se passe sur l’instance jetable « Maison Valmont », decrite dans ../instance-jetable/README.md : un WordPress Docker local, corpus 100 % synthetique, aucune donnee reelle. Les captures et les mesures publiees ici ne montrent que ce cadre neutre.

Prerequis

  1. l’instance jetable demarree (conteneurs valmont-*) ;
  2. ../instance-jetable/.env renseigne — ce fichier n’est jamais commite, et aucune valeur reelle n’apparait dans ce notebook ;
  3. pip install requests python-dotenv playwright puis playwright install chromium.

Ce que le notebook modifie, et rend : il bascule des indicateurs module_* du plugin. La derniere cellule les remet a leur etat Free d’origine et le verifie. Aucun contenu, aucun utilisateur, aucun chatbot n’est touche.

# Configuration et helpers. Aucune cle ni adresse n'est stockee dans ce
# fichier : tout vient de instance-jetable/.env.

import asyncio
import base64
import json
import sys
import threading
import os
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

charges = []
for candidat in (Path("../instance-jetable/.env"), 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", "")
SESSION_PASSWORD = os.getenv("VALMONT_ADMIN_SESSION_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):
    """Appel a l'API d'administration, en tant qu'admin (app password)."""
    r = requests.request(method, BASE_URL + "/wp-json" + route,
                         headers=ENTETES, json=payload, timeout=120)
    r.raise_for_status()
    return r.json()


def lire_options():
    """L'integralite des reglages du plugin, tels que le serveur les livre."""
    return api("/mwai/v1/settings/options")["options"]


def ecrire_options(options):
    """Ecrit le blob complet de reglages. C'est le contrat de l'endpoint."""
    return api("/mwai/v1/settings/update", "POST", {"options": options})


def poser(indicateur, valeur):
    """Bascule un seul indicateur, en relisant d'abord le blob."""
    o = lire_options()
    o[indicateur] = valeur
    ecrire_options(o)
    return lire_options()[indicateur]


def en_arriere_plan(travail):
    """Execute du code Playwright synchrone depuis un noyau Jupyter.

    Playwright pilote un vrai processus navigateur. Le noyau, lui, tourne
    deja dans une boucle asyncio qui, sous Windows, ne sait pas lancer de
    sous-processus. On execute donc le travail dans un fil dedie, muni de
    sa propre boucle, et on rend la politique d'origine en sortant.
    """
    resultat, souci = {}, []
    politique = asyncio.get_event_loop_policy()

    def cible():
        if sys.platform == "win32":
            asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
        try:
            resultat["valeur"] = travail()
        except BaseException as erreur:      # remontee telle quelle au notebook
            souci.append(erreur)

    fil = threading.Thread(target=cible)
    fil.start()
    fil.join()
    asyncio.set_event_loop_policy(politique)
    if souci:
        raise souci[0]
    return resultat.get("valeur")


SUJET = "module_workspace"   # l'indicateur temoin de tout le parcours
Fichiers .env charges : ['..\\instance-jetable\\.env']
Base URL : http://localhost:8093

Palier 0 — l’etat, vu de l’API

On commence par le plus simple : demander au serveur ce qu’il pense de ses propres modules. La reponse est nette, et c’est bien la le piege a venir.

options = lire_options()
modules = sorted(k for k in options if k.startswith("module_"))

print(f"{len(modules)} indicateurs module_* exposes par l'API")
print(f"  actifs : {sum(1 for k in modules if options[k])}")
print()
print(f"Indicateur temoin de ce parcours : {SUJET}")
print(f"  valeur servie par l'API : {options[SUJET]}")
19 indicateurs module_* exposes par l'API
  actifs : 14

Indicateur temoin de ce parcours : module_workspace
  valeur servie par l'API : False

Ce que cette reponse etablit – et ce qu’elle n’etablit pas

L’API sert 19 indicateurs module_* : 14 actifs, 5 inactifs. C’est l’etat officiel du serveur, celui que verrait un tableau de bord, un script d’exploitation ou un test d’integration classique – une photographie nette, prise par le seul canal que ces outils connaissent. Le temoin choisi, module_workspace, arrive a False : le serveur le declare inactif, donc toute ecriture qui le rendrait True se lira sans ambiguite sur ce meme canal, et l’inverse aussi.

Ce que la photographie ne montre pas, c’est le mouvement. Rien ici ne dit si cet etat est stable : pour le savoir, il faudrait ecrire, puis verifier que l’ecriture tient. C’est exactement le programme des paliers suivants – chaque palier ajoute UN acteur (l’API, puis le navigateur) et ne conclut que sur ce que ses seuls yeux peuvent voir.

Palier 1 — on ecrit par l’API, et l’API confirme

Un scenario d’integration tout a fait ordinaire : activer un module par l’API, puis verifier qu’il est actif. Deux appels, deux reponses concordantes.

avant = lire_options()[SUJET]
apres = poser(SUJET, True)

print(f"POST settings/update  :  {SUJET}  {avant}  ->  {apres}")
assert apres is True, "l'ecriture REST n'a pas pris"
print()
print("Un test d'integration qui ne parle qu'a l'API s'arrete ici.")
print("Il est vert, et il a raison de l'etre : le serveur a bien ecrit.")
POST settings/update  :  module_workspace  False  ->  True

Un test d'integration qui ne parle qu'a l'API s'arrete ici.
Il est vert, et il a raison de l'etre : le serveur a bien ecrit.

Le vert du test, lu de pres

Le serveur a repondu favorablement : False -> True est maintenant l’etat servi par l’API. Un test d’integration qui ne parle qu’a l’API s’arrete sur ce vert, et il a raison : ce qu’il pretendait verifier – « le serveur accepte cette ecriture » – est etabli.

Ce que ce vert ne couvre pas : ce que fera le reste du systeme a cette nouvelle valeur. Le serveur d’une application reelle n’est pas un tutoriel d’API ; autour de la base de donnees vivent des clients qui ont leur propre opinion sur les reglages. Le palier suivant introduit le plus influent de tous : le navigateur d’administration, ouvert par n’importe quel humain qui consulte la page – sans meme avoir besoin de cliquer.

Palier 2 — on ouvre une page de reglages, et on ne touche a rien

Aucun clic, aucun formulaire, aucune sauvegarde. On se connecte, on charge admin.php?page=mwai_settings, on attend que le reseau se calme, on ferme. On se contente d’ecouter ce que le navigateur envoie de lui-meme.

from playwright.sync_api import sync_playwright


def ouvrir_les_reglages(page_admin="/wp-admin/admin.php?page=mwai_settings",
                        indicateurs=(SUJET,), repos=4.0):
    """Ouvre une page d'admin dans un navigateur NEUF et rapporte les
    POST settings/update que la page emet d'elle-meme. Ne clique rien."""
    envois = []
    with sync_playwright() as p:
        navigateur = p.chromium.launch()
        page = navigateur.new_page(viewport={"width": 1400, "height": 900})
        page.goto(BASE_URL + "/wp-login.php", wait_until="domcontentloaded")
        page.fill("#user_login", ADMIN_USER)
        page.fill("#user_pass", SESSION_PASSWORD)
        page.click("#wp-submit")
        page.wait_for_load_state("networkidle")

        def au_depart(requete):
            if "settings/update" in requete.url and requete.method == "POST":
                corps = json.loads(requete.post_data or "{}").get("options", {})
                envois.append({k: corps.get(k) for k in indicateurs})

        page.on("request", au_depart)
        page.goto(BASE_URL + page_admin, wait_until="networkidle")
        time.sleep(repos)
        navigateur.close()
    return envois


envois = en_arriere_plan(ouvrir_les_reglages)
print(f"POST settings/update emis par le navigateur, sans aucun clic : {len(envois)}")
for i, corps in enumerate(envois, 1):
    print(f"  envoi {i} : {corps}")
assert envois, "la page n'a rien envoye — le phenomene ne se produit pas sur cette instance"
POST settings/update emis par le navigateur, sans aucun clic : 2
  envoi 1 : {'module_workspace': True}
  envoi 2 : {'module_workspace': False}

Pourquoi une page qui ne fait rien ecrit deux fois

Le resultat est surprenant au premier regard : aucun clic, aucune saisie, et pourtant deux POST settings/update partis vers le serveur. La page de reglages n’est pas un document passif : en se chargeant, elle lit l’etat des cases a cocher, construit sa representation du formulaire… et le mecanisme de sauvegarde du formulaire d’options emet ses ecritures de lui meme.

Les deux envois ne disent pas la meme chose. Le premier repete l’etat que le serveur vient d’admettre (True) : c’est l’echo de notre ecriture d’API. Le second applique la regle du client (False) : l’etat que la page croit etre le bon, calcule cote navigateur. Ces deux opinions vont se croiser sur le serveur – et le palier suivant regarde laquelle survit.

Palier 3 — qui a gagne ?

Le navigateur a envoye deux ecritures contradictoires sur le meme reglage. Le serveur a repondu aux deux. Reste a savoir ce qu’il a garde.

final = lire_options()[SUJET]

print(f"{SUJET} : ecrit a True par l'API, puis page ouverte sans un clic")
print(f"  valeur retenue par le serveur : {final}")
print()
if final is True:
    print("Cette fois, l'ecriture de l'API a survecu.")
else:
    print("Cette fois, le navigateur a defait l'ecriture de l'API.")
print("Le mot important est « cette fois ». Palier suivant.")
module_workspace : ecrit a True par l'API, puis page ouverte sans un clic
  valeur retenue par le serveur : False

Cette fois, le navigateur a defait l'ecriture de l'API.
Le mot important est « cette fois ». Palier suivant.

Cette fois, le navigateur a defait l’API

Valeur retenue : False. L’ecriture de l’API (palier 1, confirmee par le serveur) a ete ecrasee par l’ouverture d’une page (palier 2, sans aucun geste). Recapitulons ce que chaque acteur pouvait croire :

  • L’API : « j’ai ecrit True, le serveur m’a repondu 200, c’est fait. »
  • Le navigateur : « la page est ouverte, l’etat des reglages est celui que je calcule, je l’enregistre. »
  • Le serveur : deux ecritures contradictoires sur le meme enregistrement, une seule valeur retenue – la derniere arrivee.

Le mot important de la sortie est « cette fois ». On a observe UNE course, avec UNE arrivee. Rien ne prouve que la prochaine course ait le meme vainqueur : peut-etre l’ordre d’arrivee des deux POST depend-il du reseau, de la charge, du minutage exact de la page. C’est la question a laquelle le palier suivant repond – en rejouant.

Une fois ne suffit pas

Si le resultat etait stable, une seule observation suffirait a conclure. On refait donc exactement la meme manipulation plusieurs fois de suite, navigateur neuf a chaque tour, en repartant chaque fois de True.

def un_tour():
    """Ecrit True par l'API, ouvre la page, rend la valeur qui survit."""
    poser(SUJET, True)
    en_arriere_plan(ouvrir_les_reglages)
    return lire_options()[SUJET]


tours = [un_tour() for _ in range(5)]
survecu = sum(1 for t in tours if t is True)

print("valeur retenue, tour par tour :", tours)
print()
print(f"  l'ecriture de l'API survit   : {survecu}/{len(tours)}")
print(f"  le navigateur la defait      : {len(tours) - survecu}/{len(tours)}")
print()
if len(set(tours)) == 1:
    print("Serie homogene sur cet echantillon — relancer la cellule pour l'eprouver.")
else:
    print("Meme manipulation, memes conditions, resultats differents :")
    print("le phenomene est intermittent. Un test qui l'observe UNE fois")
    print("ne rapporte pas l'etat du systeme, il rapporte son tirage.")
valeur retenue, tour par tour : [False, True, False, True, True]

  l'ecriture de l'API survit   : 3/5
  le navigateur la defait      : 2/5

Meme manipulation, memes conditions, resultats differents :
le phenomene est intermittent. Un test qui l'observe UNE fois
ne rapporte pas l'etat du systeme, il rapporte son tirage.

Ce que 3/5 veut dire pour un test automatise

Cinq tours, memes gestes, memes conditions : trois ecritures de l’API ont survecu, deux ont ete defaites. Le phenomene n’est donc ni constant ni absent : il est intermittent.

Pour l’assurance-qualite, c’est le pire des regimes – et le plus repandu. Un test qui observe le systeme UNE fois et echoue dira « bug » la ou le tirage etait simplement defavorable ; un test qui passe dira « conforme » la ou le defaut existe, simplement invisible ce tour-la. La reponse classique est le rejeu en cas d’echec (« flaky retry ») : c’est une facon honnete de mesurer un taux, et une facon tres malhonnete de le cacher – un test rejoue jusqu’a vert ne mesure plus le systeme, il mesure la patience de l’orchestrateur.

Ce parcours choisit l’autre voie : chronometrer la course pour comprendre pourquoi le tirage varie, au lieu de la rejouer jusqu’a ce qu’elle dise ce qu’on veut entendre.

La course, chronometree

Deux ecritures concurrentes sur le meme enregistrement : c’est la forme classique du lost update. Chaque requete lit le blob de reglages, le modifie, le reecrit en entier. Celle qui ecrit en dernier efface l’autre — et rien, cote client, ne dit laquelle ce sera.

On date les envois et les reponses pour le voir. On en profite pour elargir : en mettant tous les indicateurs a True avant d’ouvrir la page, la difference entre le premier et le second envoi donne la liste exacte de ceux que l’application force a False.

def chronometrer_la_course(indicateurs):
    """Date les POST settings/update et rend (chronologie, envois)."""
    chrono, envois, t0 = [], [], []

    with sync_playwright() as p:
        navigateur = p.chromium.launch()
        page = navigateur.new_page(viewport={"width": 1400, "height": 900})
        page.goto(BASE_URL + "/wp-login.php", wait_until="domcontentloaded")
        page.fill("#user_login", ADMIN_USER)
        page.fill("#user_pass", SESSION_PASSWORD)
        page.click("#wp-submit")
        page.wait_for_load_state("networkidle")

        def instant():
            if not t0:
                t0.append(time.time())
            return round((time.time() - t0[0]) * 1000)

        def au_depart(requete):
            if "settings/update" in requete.url and requete.method == "POST":
                corps = json.loads(requete.post_data or "{}").get("options", {})
                envois.append({k: corps.get(k) for k in indicateurs})
                chrono.append(("envoi  ", instant(), corps.get(SUJET)))

        def a_l_arrivee(reponse):
            if "settings/update" in reponse.url and reponse.request.method == "POST":
                corps = json.loads(reponse.request.post_data or "{}").get("options", {})
                chrono.append(("reponse", instant(), f"{corps.get(SUJET)} -> HTTP {reponse.status}"))

        page.on("request", au_depart)
        page.on("response", a_l_arrivee)
        page.goto(BASE_URL + "/wp-admin/admin.php?page=mwai_settings", wait_until="networkidle")
        time.sleep(5.0)
        navigateur.close()
    return chrono, envois


tous = sorted(k for k in lire_options() if k.startswith("module_"))
o = lire_options()
for k in tous:
    o[k] = True
ecrire_options(o)

chrono, envois = en_arriere_plan(lambda: chronometrer_la_course(tous))

for genre, ms, valeur in chrono:
    print(f"  t+{ms:5d} ms   {genre}   {SUJET} = {valeur}")

if len(envois) >= 2:
    forces = sorted(k for k in tous if envois[0].get(k) and not envois[1].get(k))
    print()
    print(f"Indicateurs que le second envoi rabat a False : {len(forces)}")
    for k in forces:
        print("   -", k.replace("module_", ""))
    print()
    print("Le premier envoi repete l'etat du serveur ; le second applique")
    print("la regle du client. Les deux partent a quelques dizaines de")
    print("millisecondes d'intervalle et se croisent cote serveur.")
  t+    0 ms   envoi     module_workspace = True
  t+   47 ms   envoi     module_workspace = False
  t+  157 ms   reponse   module_workspace = True -> HTTP 200
  t+  159 ms   reponse   module_workspace = False -> HTTP 200

Indicateurs que le second envoi rabat a False : 5
   - embeddings
   - forms
   - orchestration
   - statistics
   - workspace

Le premier envoi repete l'etat du serveur ; le second applique
la regle du client. Les deux partent a quelques dizaines de
millisecondes d'intervalle et se croisent cote serveur.

Lire la course au millieme

La chronologie raconte tout. Les deux envois partent a 0 ms et 47 ms – le premier (l’echo de l’etat serveur), puis le client qui applique sa regle. Les reponses reviennent a 157 ms et 159 ms : 200 pour l’un, 200 pour l’autre. Le serveur a accepte les deux, il n’a arbitre aucun conflit : du point de vue du protocole, aucune de ces ecritures n’est une erreur.

C’est la definition meme d’une course d’ecriture (write skew temporel) : l’ordre d’arrivee au serveur decide de la valeur retenue, et cet ordre depend de dizaines de millisecondes de reseau et de traitement. D’ou l’intermittence du palier precedent : parfois les paquets se croisent dans un sens, parfois dans l’autre.

La sortie nomme aussi le cout collateral : 5 indicateurs rabattus a False par le second envoi (embeddings, forms, orchestration, statistics, workspace). L’ouverture d’une page de reglages n’a pas defait un temoin isole : elle a reimpose l’ETAT ENTIER du formulaire tel que le client le calcule. Un test qui ne surveille que son indicateur temoin passerait vert pendant que quatre autres reglages changent sous ses yeux.

Lecture

1. Le verrou est cote client. Le serveur accepte ces modules : il a repondu 200 aux deux ecritures, et la valeur True survit une fois sur plusieurs. Ce n’est donc pas le serveur qui refuse. C’est l’application d’administration qui, au montage, rabat une liste d’indicateurs a False et la renvoie. Le paquet JavaScript de la page transporte cette liste — la cellule precedente la reconstitue sans lire une seule ligne de code, uniquement d’apres ce qui passe sur le reseau.

2. Aucune sonde d’API ne pouvait voir cela. Les six grains de la serie « par son API » interrogent le serveur, et le serveur dit vrai : il a bien ecrit ce qu’on lui a demande. Le desaccord n’existe qu’a l’instant ou un navigateur charge la page. Une suite de tests REST resterait verte indefiniment pendant que la fonctionnalite est eteinte pour tout utilisateur reel.

3. Et une sonde de navigateur ne suffit pas non plus, si elle n’echantillonne qu’une fois. C’est le point le plus couteux en pratique. Le phenomene est intermittent : lancer le test une fois donne un verdict tire au sort. Vert, on ferme le ticket ; rouge, on soupconne le test d’etre instable et on le relance jusqu’a ce qu’il passe. Les deux reflexes menent au meme endroit.

La contre-mesure n’est pas une meilleure assertion sur un tour, c’est un taux : repeter, compter, rapporter la proportion. Un test qui rend « 3 fois sur 5 » dit quelque chose de vrai sur le systeme ; un test qui rend « OK » ne dit rien tant qu’on ignore combien de fois il a regarde.

Ce parcours prolonge verification-verte-systeme-casse.md, qui traite des sondes mesurant le contenant plutot que le contenu. Ici la sonde mesure la bonne chose — elle la mesure simplement trop peu de fois.

Exercices

Trois pistes, a completer sur l’instance jetable. Les squelettes ci-dessous ne rendent rien : c’est a vous d’ecrire le corps.

A vous : construire les instruments manquants

Les trois exercices qui suivent reprennent chacun un fil laisse ouvert par le parcours. Le premier donne un taux (2/5) et demande quand un tel taux devient une mesure plutot qu’un bruit – c’est la question du nombre de tours, celle que tout test flaky pose un jour. Le deuxieme part d’un fait etabli ici (la page de reglages ecrit) et cartographie quelles autres pages ecrivent aussi. Le troisieme est le plus proche du metier : ecrire la sonde qui aurait attrape ce defaut en integration continue, c’est-a- dire echouer de facon FIABLE sur un phenomene intermittent – le point dur de tout test de concurrence. Les squelettes sont volontairement silencieux : a completer sur l’instance jetable, sans cle ni adresse.

# Exercice 1 — a partir de combien de tours la mesure devient-elle
# informative ? Rendre le taux observe ET de quoi juger sa fiabilite
# (l'intervalle de Wilson est un bon choix pour de petits echantillons).
#
# Question a trancher : avec 5 tours et 0 echec observe, que peut-on
# honnetement affirmer sur le taux reel ?

def taux_d_ecrasement(tours=10):
    """Rend (taux, borne_basse, borne_haute) pour l'ecrasement du SUJET."""
    # TODO : repeter un_tour(), compter les survivances, puis encadrer
    # la proportion obtenue. Indice : la borne de Wilson se calcule a la
    # main en une dizaine de lignes, sans dependance supplementaire.
    return None
# Exercice 2 — toutes les pages d'administration ne declenchent pas ces
# ecritures. Mesurer lesquelles, en comptant les POST settings/update
# emis par chacune. Reutiliser en_arriere_plan() + ouvrir_les_reglages.
#
# Indice : comparer une page etrangere au plugin, l'accueil du plugin,
# et la page de reglages proprement dite.

PAGES = [
    "/wp-admin/index.php",
    "/wp-admin/admin.php?page=mwai_dashboard",
    "/wp-admin/admin.php?page=mwai_settings",
]


def pages_qui_ecrivent(pages=PAGES):
    """Rend {page: nombre de POST settings/update emis}."""
    # TODO : pour chaque page, brancher un compteur sur page.on("request")
    # avant de naviguer. Indice : reprendre la structure de
    # chronometrer_la_course() en ne gardant que le comptage.
    return None
# Exercice 3 — ecrire la sonde qu'on aurait voulu avoir. Elle doit
# echouer de facon FIABLE sur un defaut intermittent, ce qu'une simple
# assertion sur un tour ne fait pas.
#
# Contraintes : choisir un nombre de tours et un seuil, les justifier,
# et faire apparaitre le taux dans le message d'echec — un rapport qui
# dit seulement « KO » recree le probleme qu'on essaie de resoudre.

def sonde_honnete(tours=10, seuil=1.0):
    """Leve AssertionError si le taux de survie passe sous le seuil."""
    # TODO : mesurer le taux sur `tours` essais, puis lever si le taux
    # passe sous le seuil. Indice : faire figurer la proportion observee
    # dans le message — un rapport qui dit seulement « KO » recree le
    # probleme que ce notebook vient de mesurer.
    return None

Restauration — et une derniere surprise

Le parcours a bascule des indicateurs. On rend donc l’instance a son etat de reference : celui que son script d’installation declare, dans ../instance-jetable/seed-valmont.php.

Ce script demande six modules actifs. Quatre d’entre eux figurent dans la liste que l’application d’administration rabat a False. Autrement dit : l’etat documente de cette instance n’est pas un etat stable. Il suffit d’aller consulter les reglages pour qu’il commence a s’en ecarter, sans que personne n’ait rien decide.

C’est une bonne facon de finir. Le desaccord n’est pas un accident de manipulation qu’on aurait provoque en trichant avec l’API : il se declenche tout seul, sur le chemin le plus banal qui soit.

# Les modules que seed-valmont.php (lignes 52-57) demande actifs.
SEED_ACTIFS = ["module_chatbots", "module_embeddings", "module_mcp",
               "module_forms", "module_workspace", "module_statistics"]

avant = lire_options()
derive = [k for k in SEED_ACTIFS if not avant[k]]

o = lire_options()
for k in SEED_ACTIFS:
    o[k] = True
ecrire_options(o)

verif = lire_options()
manquants = [k for k in SEED_ACTIFS if not verif[k]]

print(f"modules que le seed declare actifs : {len(SEED_ACTIFS)}")
print(f"  ecartes de cet etat en arrivant ici : {len(derive)}")
for k in derive:
    print("   -", k.replace("module_", ""))
print(f"  reactives : {len(SEED_ACTIFS) - len(manquants)}/{len(SEED_ACTIFS)}")
assert not manquants, f"restauration incomplete : {manquants}"
print()
print("Instance rendue a l'etat que son seed declare.")
print("Elle s'en ecartera de nouveau a la prochaine page de reglages ouverte,")
print("et c'est exactement ce que ce parcours vient de mesurer.")
print("Aucun contenu, aucun utilisateur, aucun chatbot n'a ete touche.")
modules que le seed declare actifs : 6
  ecartes de cet etat en arrivant ici : 4
   - embeddings
   - forms
   - workspace
   - statistics
  reactives : 6/6

Instance rendue a l'etat que son seed declare.
Elle s'en ecartera de nouveau a la prochaine page de reglages ouverte,
et c'est exactement ce que ce parcours vient de mesurer.
Aucun contenu, aucun utilisateur, aucun chatbot n'a ete touche.

L’etat rendu, et ce qu’il faut en retenir

La restauration a reactive les 6 modules du seed (6/6) ; les 4 ecartes en arrivant ici (embeddings, forms, workspace, statistics) sont exactement les indicateurs que la course du palier 4 rabattait – la signature du defaut, visible jusque dans le menage final. Aucun contenu, aucun utilisateur, aucun chatbot n’a ete touche : le parcours n’a fait que pousser des reglages et les rendre.

La derniere ligne de la sortie merite une relecture : l’instance s’ecartera de nouveau a la prochaine page de reglages ouverte. La restauration ne corrige rien du defaut – elle remet le decor pour le prochain passage. C’est la discipline d’un parcours QA reproductible : chaque execution commence et finit dans l’etat que le seed declare, et le defaut, lui, reste entierement a documenter et a corriger ailleurs.

Voir aussi

Retour au sommet