11 KiB
Audit — Perte de window_title dans le pipeline mémoire
Date : 2026-05-19
Mission : Claude 3 (lecture seule)
Périmètre : agent_v0/server_v1/stream_processor.py, api_stream.py, replay_engine.py, workflow_replay.py, replay_memory.py
Branche : backup/post-demo-2026-05-19 (HEAD 5ea4960e6)
Référence : blocage P0 #3 de docs/AUDIT_LEA_FIRST_RUNTIME_2026-05-19.md
Constat
La mémoire persistante (TargetMemoryStore) repose sur une signature d'écran sha256(window_title) (cf. replay_memory.py:94-103). Sans window_title, la signature est vide → ni memory_lookup ni memory_record_* ne se déclenchent. Le code utilise try/except permissif et un if _window_title silencieux — aucun signal n'est émis quand la mémoire est skip.
L'audit identifie deux problèmes simultanés :
-
Asymétrie écriture/lecture : plusieurs chemins de production écrivent
window_titlesur l'action top-level (action["window_title"]ouaction["expected_window_before"]), mais la lecture mémoire (api_stream.py:3634-3639) cherche uniquement danstarget_spec. Conséquence : la fallbackor _mem_target_spec.get("expected_window_before", "")ne peut jamais réussir car ce champ n'est posé que sur l'action top-level. -
Producteurs incomplets : plusieurs constructeurs de
target_specn'injectent paswindow_titlemême quand l'information est disponible dans le contexte.
Résultat net : sur le chemin Léa-first natif (capture → workflow construit par build_replay_from_raw_events), la mémoire ne se déclenche jamais bien que window_title soit présent sur l'action.
Tableau — cartographie des chemins
Producteurs target_spec / actions click
| Fichier | Fonction / site | Chemin | window_title dans target_spec |
Impact mémoire |
|---|---|---|---|---|
stream_processor.py:1532-1601 |
build_replay_from_raw_events (raw events Léa → actions) |
Léa-first natif | JAMAIS posé dans target_spec — écrit top-level ligne 1545 | 🔴 lookup + record toujours skip |
stream_processor.py:1590-1601 |
branche enrich post-Léa (anchor + window_capture) | Léa-first natif | non — propage enrichment (by_text, anchor) + window_capture.rect, jamais window_title |
🔴 même chemin que 1545 |
stream_processor.py:4396-4443 |
_create_edge_action (worker VLM offline, GraphBuilder edges) |
workflow construit hors session | OK ligne 4402 : if window_title: target_spec['window_title'] = window_title |
🟢 mémoire active si node metadata contient window_title |
replay_engine.py:534-548 |
_generate_setup_env_actions clic Démarrer |
replay-session bootstrap | AUCUN window_title posé (légitime : fenêtre Bureau Windows) | 🟡 dette assumée — clic système |
replay_engine.py:563-578 |
_generate_setup_env_actions clic Rechercher |
replay-session bootstrap | AUCUN window_title posé (légitime : menu Démarrer) | 🟡 dette assumée |
replay_engine.py:611-625 |
_generate_setup_env_actions clic résultat app |
replay-session bootstrap | AUCUN window_title posé (résultats recherche volatils) | 🟡 dette assumée |
replay_engine.py:966-979 |
_normalize_action (action depuis objet Target) |
normalisation chemin Target API | by_role, by_text, context_hints posés — PAS window_title | 🔴 lookup + record skip |
replay_engine.py:1804-1807 |
_create_replay_state slim copy |
tous chemins (post-construction) | conserve si présent ; strip uniquement anchor_image_base64 |
🟢 transparent |
workflow_replay.py:119-128 |
build_workflow_replay (orphelin) |
branche non branchée | OK ligne 123 : "window_title": node_title posé correctement |
⚫ code mort, 0 caller runtime |
Lecteurs window_title (sites memory)
| Fichier | Fonction / site | Cherche dans | Statut |
|---|---|---|---|
replay_memory.py:142-206 |
memory_lookup |
target_spec.get("window_title", "") |
🔴 silencieusement skip si vide |
api_stream.py:3634-3639 |
memory_record_success/failure source | _mem_target_spec.get("window_title", "") puis _mem_target_spec.get("expected_window_before", "") |
🔴 deuxième fallback inopérante — expected_window_before n'est jamais dans target_spec |
resolve_engine.py:1541 |
déclenche memory_lookup | target_spec.get("window_title", "") |
🔴 propagation du silence |
api_stream.py:3278-3281 |
log REPLAY (observabilité) | action.get("expected_window_before") or _tspec.get("window_title", "") |
🟢 correct — preuve que les 2 endroits existent et qu'au moins ce code le sait |
api_stream.py:3599 |
audit_trail target_app |
_target_spec.get("window_title", "") |
🔴 audit incomplet sur chemins où window_title est top-level |
stream_processor.py:1149, 1176 |
_enrich_actions_with_intentions (user-facing) |
action.get("target_spec", {}).get("window_title", "") |
🟡 affiche '?' ou 'inconnue' quand absent |
Producteurs top-level action["window_title"] ou expected_window_before
| Fichier | Site | Champ posé | Présent dans target_spec ? |
|---|---|---|---|
stream_processor.py:1545 |
build_replay_from_raw_events |
action["window_title"] = window["title"] |
non |
replay_engine.py:1797-1798 |
_create_replay_state slim copy |
a_copy["expected_window_before"], a_copy["expected_window_title"] |
non |
Cas critiques (chemins où la mémoire skip silencieusement)
Cas A — Session Léa fraîchement enregistrée, workflow direct
Trigger : utilisateur enregistre une session, finalize enqueue, build_replay_from_raw_events produit les actions.
Chemin : stream_processor.py:1532-1601.
Symptôme : action["window_title"] est posé au top-level (ligne 1545), mais le target_spec (lignes 1590-1601) ne contient que enrichment + window_capture. Au replay, memory_record_success lit _mem_target_spec.get("window_title", "") → vide → compute_screen_sig → "" → skip silencieux.
Conséquence produit : la voie nominale Léa-first n'alimente jamais la mémoire. Aucune leçon stockée.
Cas B — Action via API Target normalisé
Trigger : un client appelle une API qui passe par _normalize_action (replay_engine.py:966).
Chemin : replay_engine.py:966-979.
Symptôme : target_spec construit avec by_role, by_text, context_hints uniquement. window_title jamais posé même si disponible dans le contexte appelant.
Conséquence produit : tout client qui passe par cette API perd la mémoire.
Cas C — Workflow setup_env autogénéré (ouvrir une app via Démarrer)
Trigger : un workflow démarre par ouvrir une app, génération automatique de 3 clics (Démarrer → Recherche → Résultat).
Chemin : replay_engine.py:534-625.
Symptôme : aucun des 3 clics n'a window_title dans target_spec. C'est intentionnel (fenêtres système volatiles), mais la mémoire ne s'active pas non plus sur ces clics.
Conséquence produit : ces 3 clics ne bénéficieront jamais de l'apprentissage par répétition, alors qu'ils sont parmi les plus stables visuellement (bouton Démarrer toujours en bas-gauche).
Cas D — Fallback expected_window_before codée mais inopérante
Trigger : action a expected_window_before posé top-level (par _create_replay_state ou un workflow VWB qui le renseigne).
Chemin : api_stream.py:3634-3639.
Symptôme : le code tente le fallback _mem_target_spec.get("expected_window_before", ""). Mais _mem_target_spec est l'objet target_spec ; expected_window_before n'est jamais dans target_spec, il est sur action top-level (cf. replay_engine.py:1797). La fallback est toujours vide.
Conséquence produit : même quand l'information existe au top-level de l'action, le memory_record ne la voit pas.
Preuve qu'il existait une intention de cohérence : api_stream.py:3278-3281 (log REPLAY) lit correctement action.get("expected_window_before") or _tspec.get("window_title"). Le code mémoire a copié la mauvaise moitié de l'expression.
Cas E — Workflow VWB édité à la main sans window_title
Trigger : workflow construit/édité dans VWB (table steps), target_spec.window_title souvent omis.
Chemin : consommé tel quel par _create_replay_state.
Symptôme : aucun warning, aucune erreur, aucun signal. La mémoire reste vide pour ce workflow.
Conséquence produit : sur Demo_urgence_3_db (46 steps), à vérifier combien de steps ont effectivement target_spec.window_title non vide — probablement minoritaire.
Conclusion
Bug réel (P0) :
- Cas D —
api_stream.py:3634-3639: la fallbackor _mem_target_spec.get("expected_window_before", "")cherche danstarget_specau lieu de l'action top-level. Code mort par erreur de copy-paste depuisapi_stream.py:3278-3281qui faisait correctementaction.get(...) or _tspec.get(...). Une ligne à corriger une fois la décision produit prise. - Cas A —
stream_processor.py:1545vs:1590-1601: asymétrie de contrat dans le même fichier, sur le chemin Léa-first nominal.window_titleest connu (posé top-level) mais non propagé danstarget_specoù la mémoire le cherche.
Dette de contrat (P1) :
- Cas B —
replay_engine.py:966-979:_normalize_actionn'inclut paswindow_titledanstarget_spec. Contrat implicite "tous les producteurs detarget_specdoivent injecterwindow_titlequand disponible" non documenté ni appliqué. - Cas E — workflows VWB sans
window_title: pas de validation côté serveur, pas de warning. La forme du contrat n'est jamais vérifiée à la création. - Cas C — clics
setup_env: exclusion légitime mais devrait être documentée. Une mémoire "setup_env" pourrait utiliser une signature d'écran différente (cf.replay_learner.py:213qui utilise"human_correction"comme signature constante — pattern réutilisable).
Branche non branchée :
workflow_replay.py:119-128: code mort qui pose pourtantwindow_titlecorrectement. Cohérent avec son orphelinat global (blocage P0 #2 de l'audit Léa-first). Pas de valeur tant qu'il n'est pas câblé.
Synthèse 1-ligne : la mémoire est branchée mais le contrat window_title in target_spec n'est respecté que par le chemin GraphBuilder (worker VLM offline) ; le chemin Léa-first nominal et la normalisation API perdent l'info, et la fallback prévue pour rattraper est inopérante par bug de lecture. Le résultat observable : TargetMemoryStore reste vide sur les sessions Léa réelles.
Méthode d'audit
- Grep cibles :
target_spec.*=,"window_title",window_title=,"type": "click"sur les 5 fichiers du périmètre. - Lectures ciblées :
stream_processor.py:1140-1180, 1530-1605, 4380-4445,replay_engine.py:525-625, 955-980, 1780-1810,api_stream.py:3270-3290, 3540-3670,workflow_replay.py(intégral),replay_memory.py(intégral). - Croisement écritures/lectures pour identifier les asymétries.
- Pas de modification de code, pas d'exploration VWB, pas de proposition de refonte — conformément aux interdits de la mission.