# 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** : 1. **Asymétrie écriture/lecture** : plusieurs chemins de production écrivent `window_title` sur l'action *top-level* (`action["window_title"]` ou `action["expected_window_before"]`), mais la lecture mémoire (`api_stream.py:3634-3639`) cherche **uniquement dans `target_spec`**. Conséquence : la fallback `or _mem_target_spec.get("expected_window_before", "")` ne peut jamais réussir car ce champ n'est posé que sur l'action top-level. 2. **Producteurs incomplets** : plusieurs constructeurs de `target_spec` n'injectent pas `window_title` mê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 fallback `or _mem_target_spec.get("expected_window_before", "")` cherche dans `target_spec` au lieu de l'action top-level. Code mort par erreur de copy-paste depuis `api_stream.py:3278-3281` qui faisait correctement `action.get(...) or _tspec.get(...)`. Une ligne à corriger une fois la décision produit prise. - **Cas A** — `stream_processor.py:1545` vs `:1590-1601` : asymétrie de contrat dans le même fichier, sur le chemin Léa-first nominal. `window_title` est connu (posé top-level) mais non propagé dans `target_spec` où la mémoire le cherche. **Dette de contrat** (P1) : - **Cas B** — `replay_engine.py:966-979` : `_normalize_action` n'inclut pas `window_title` dans `target_spec`. Contrat implicite "tous les producteurs de `target_spec` doivent injecter `window_title` quand 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:213` qui utilise `"human_correction"` comme signature constante — pattern réutilisable). **Branche non branchée** : - `workflow_replay.py:119-128` : code mort qui pose pourtant `window_title` correctement. 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.