diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py index d68f572d5..38813c5a0 100644 --- a/agent_v0/agent_v1/core/executor.py +++ b/agent_v0/agent_v1/core/executor.py @@ -767,6 +767,15 @@ class ActionExecutorV1: f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}" ) self._click((real_x, real_y), button) + # Phase 1 apprentissage : exposer les coordonnées RÉSOLUES + # utilisées pour le clic. Le serveur (/replay/result) les lit + # directement comme source de vérité pour la mémoire. + # On donne des percentages car la mémoire est indépendante + # de la résolution écran du client. + result["actual_position"] = { + "x_pct": float(x_pct), + "y_pct": float(y_pct), + } logger.info( f"Replay click [{mode}] : ({x_pct:.3f}, {y_pct:.3f}) -> " f"({real_x}, {real_y}) sur ({width}x{height})" @@ -1509,6 +1518,8 @@ Example: x_pct=0.50, y_pct=0.30""" "resolution_method": result.get("resolution_method"), "resolution_score": result.get("resolution_score"), "resolution_elapsed_ms": result.get("resolution_elapsed_ms"), + # Coordonnées RÉSOLUES effectivement cliquées (Phase 1 apprentissage) + "actual_position": result.get("actual_position"), # Champs enrichis pour target_not_found (pause supervisée) "target_description": result.get("target_description"), "target_spec": result.get("target_spec"), diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index f0a2de002..71589f14c 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -480,7 +480,7 @@ class ReplayResultReport(BaseModel): screenshot: Optional[str] = None # Chemin ou base64 du screenshot post-action screenshot_after: Optional[str] = None # Chemin ou base64 du screenshot APRES l'action screenshot_before: Optional[str] = None # Screenshot AVANT l'action (pour le Critic) - actual_position: Optional[Dict[str, float]] = None # {"x": px, "y": py} position réelle du clic + actual_position: Optional[Dict[str, float]] = None # {"x_pct": float, "y_pct": float} coords résolues effectivement cliquées # Métriques de résolution visuelle resolution_method: Optional[str] = None # som_text_match, som_vlm, vlm_quick_find, etc. resolution_score: Optional[float] = None @@ -1796,6 +1796,7 @@ async def start_replay(request: ReplayRequest): total_actions=len(actions), params=params, machine_id=resolved_machine_id, + actions=actions, ) # Enregistrer le mapping machine -> session pour le replay ciblé if resolved_machine_id and resolved_machine_id != "default": @@ -1890,6 +1891,7 @@ async def start_raw_replay(request: RawReplayRequest): total_actions=len(actions), params={}, machine_id=resolved_machine_id, + actions=actions, ) # Enregistrer le mapping machine -> session pour le replay ciblé if resolved_machine_id and resolved_machine_id != "default": @@ -2089,6 +2091,7 @@ async def replay_from_session( total_actions=len(actions), params={}, machine_id=machine_id, + actions=actions, ) # Enregistrer le mapping machine -> session pour le replay ciblé if machine_id and machine_id != "default": @@ -2345,6 +2348,7 @@ async def launch_replay_from_plan(request: PlanReplayRequest): total_actions=len(validated), params=dict(plan.variables or {}), machine_id=resolved_machine_id, + actions=validated, ) if resolved_machine_id and resolved_machine_id != "default": _machine_replay_target[resolved_machine_id] = target_session_id @@ -3023,6 +3027,69 @@ async def report_action_result(report: ReplayResultReport): except Exception as e: logger.debug(f"Audit Trail: échec enregistrement: {e}") + # === Apprentissage persistant (Phase 1 plan Léa — Fiche #18) === + # Single source of truth : l'agent remplit `report.actual_position` + # avec les coordonnées percentages qu'il a effectivement cliquées + # (après résolution visuelle). Le serveur les lit directement — pas + # de cache intermédiaire entre /resolve_target et /replay/result. + # + # On lit aussi le `target_spec` de l'action courante depuis + # `replay_state["actions"]`, qui contient la copie slim stockée au + # démarrage du replay (cf. _create_replay_state). + # + # Garde stricte : on ne mémorise que les clics (type == "click"). + # On traite cette branche AVANT d'incrémenter current_action_index. + try: + from .replay_memory import memory_record_success, memory_record_failure + + _idx = replay_state.get("current_action_index", 0) + _actions_meta = replay_state.get("actions", []) + if 0 <= _idx < len(_actions_meta): + _current = _actions_meta[_idx] or {} + if _current.get("type") == "click": + _mem_target_spec = _current.get("target_spec") or {} + _mem_window_title = ( + _mem_target_spec.get("window_title", "") + or _mem_target_spec.get("expected_window_before", "") + ) + + if _mem_window_title: + _mem_success = ( + report.success and (verification is None or verification.verified) + ) + if _mem_success: + # Lire les coordonnées RÉSOLUES directement depuis + # le rapport de l'agent. Format attendu : + # actual_position = {"x_pct": float, "y_pct": float} + _pos = report.actual_position or {} + _x_pct = _pos.get("x_pct") if isinstance(_pos, dict) else None + _y_pct = _pos.get("y_pct") if isinstance(_pos, dict) else None + + if _x_pct is not None and _y_pct is not None: + memory_record_success( + window_title=_mem_window_title, + target_spec=_mem_target_spec, + x_pct=float(_x_pct), + y_pct=float(_y_pct), + method=(report.resolution_method or "v4_unknown"), + confidence=float(report.resolution_score or 0.9), + ) + else: + logger.debug( + "memory_record skipped: actual_position absent " + "ou sans x_pct/y_pct (agent pas à jour ?)" + ) + else: + memory_record_failure( + window_title=_mem_window_title, + target_spec=_mem_target_spec, + error_message=( + report.error or report.warning or "post_cond_failed" + ), + ) + except Exception as _mem_exc: + logger.debug("Memory record skipped : %s", _mem_exc) + with _replay_lock: # === Logique de retry / success / failure === if report.success and (verification is None or verification.verified): diff --git a/agent_v0/server_v1/replay_engine.py b/agent_v0/server_v1/replay_engine.py index b5c8bac41..5cdb09af6 100644 --- a/agent_v0/server_v1/replay_engine.py +++ b/agent_v0/server_v1/replay_engine.py @@ -1147,8 +1147,35 @@ def _create_replay_state( total_actions: int, params: Optional[Dict[str, Any]] = None, machine_id: Optional[str] = None, + actions: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: - """Créer un état de replay enrichi avec les champs de suivi d'erreur.""" + """Créer un état de replay enrichi avec les champs de suivi d'erreur. + + Args: + actions: Liste des actions du replay. Une copie slim (sans anchors + base64) est stockée pour permettre à `/replay/result` de + retrouver le `target_spec` de l'action courante — nécessaire + pour l'apprentissage mémoire (Phase 1 plan Léa). + """ + # Copie slim des actions : on strip les anchor_image_base64 pour ne + # pas gonfler la mémoire (anchors peuvent faire 50-200 KB chacun). + actions_slim: List[Dict[str, Any]] = [] + if actions: + for a in actions: + a_copy = { + "action_id": a.get("action_id"), + "type": a.get("type"), + "x_pct": a.get("x_pct"), + "y_pct": a.get("y_pct"), + } + ts = a.get("target_spec") + if isinstance(ts, dict): + a_copy["target_spec"] = { + k: v for k, v in ts.items() + if k not in ("anchor_image_base64",) + } + actions_slim.append(a_copy) + return { "replay_id": replay_id, "workflow_id": workflow_id, @@ -1161,6 +1188,7 @@ def _create_replay_state( "current_action_index": 0, "params": params or {}, "results": [], # Historique des résultats action par action + "actions": actions_slim, # Copie slim pour lookup par index (Phase 1 mémoire) # Champs enrichis pour le suivi d'erreur (#7) "retried_actions": 0, "unverified_actions": 0, diff --git a/agent_v0/server_v1/replay_memory.py b/agent_v0/server_v1/replay_memory.py new file mode 100644 index 000000000..374d0ad2e --- /dev/null +++ b/agent_v0/server_v1/replay_memory.py @@ -0,0 +1,316 @@ +# agent_v0/server_v1/replay_memory.py +""" +replay_memory — Greffe de TargetMemoryStore (Fiche #18) sur le pipeline V4. + +Phase 1 du plan apprentissage Léa (docs/PLAN_APPRENTISSAGE_LEA.md). + +Le runtime V4 appelle : + - `memory_lookup()` AVANT la cascade coûteuse (OCR/template/VLM) + - `memory_record_success()` APRÈS validation post-condition (`title_match` strict) + - `memory_record_failure()` sur les échecs + +Fingerprint léger V4 : les coordonnées clic (x_pct, y_pct) sont stockées dans +les deux premières valeurs de `TargetFingerprint.bbox`, et la méthode de +résolution ayant réussi dans le champ `etype`. + +Signature d'écran V4 : `sha256(normalize(window_title))[:16]`. Simple et +robuste aux données variables car les titres de fenêtre restent stables. +Les faux positifs (même titre, écrans différents) sont rattrapés par la +post-condition qui décrémentera la fiabilité via `record_failure()`. + +Critère de fiabilité : 2 succès minimum et < 30% d'échecs pour déclencher +un hit (paramètres de `TargetMemoryStore.lookup`). C'est exactement la +cristallisation par répétition que l'on veut — Léa est un stagiaire qui +apprend de l'observation. + +Auteur : Dom, Alice — avril 2026 +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import unicodedata +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +# ========================================================================= +# Singleton du store persistant +# ========================================================================= + +_MEMORY_SINGLETON: Optional[Any] = None +_MEMORY_DISABLED = False + + +def get_memory_store(): + """Retourne le `TargetMemoryStore` partagé, ou None si indisponible. + + Lazy-init : le store n'est créé qu'au premier appel, ce qui évite + d'importer `core.learning.target_memory_store` à l'import du module + (et donc d'éviter les effets de bord sur le démarrage du serveur). + """ + global _MEMORY_SINGLETON, _MEMORY_DISABLED + + if _MEMORY_DISABLED: + return None + if _MEMORY_SINGLETON is not None: + return _MEMORY_SINGLETON + + try: + from core.learning.target_memory_store import TargetMemoryStore + + base_path = os.environ.get("RPA_LEARNING_DIR", "data/learning") + _MEMORY_SINGLETON = TargetMemoryStore(base_path=base_path) + logger.info( + "replay_memory: TargetMemoryStore initialisé (base=%s)", base_path, + ) + return _MEMORY_SINGLETON + except Exception as exc: + logger.warning( + "replay_memory: TargetMemoryStore indisponible (%s) — " + "l'apprentissage persistant est désactivé", exc, + ) + _MEMORY_DISABLED = True + return None + + +# ========================================================================= +# Normalisation de texte et hash +# ========================================================================= + + +def _norm_text(s: str) -> str: + """Normalise un texte pour un hash stable (accents, casse, NBSP, espaces).""" + if not s: + return "" + s = s.replace("\u00A0", " ").strip().lower() + s = unicodedata.normalize("NFKD", s) + s = "".join(ch for ch in s if not unicodedata.combining(ch)) + return " ".join(s.split()) + + +def compute_screen_sig(window_title: str) -> str: + """Calcule la signature d'écran V4 à partir du titre de fenêtre. + + Le `window_title` est strict depuis la phase "controle des étapes" + (post-condition `title_match` obligatoire). C'est notre clé naturelle. + """ + norm = _norm_text(window_title) + if not norm: + return "" + return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] + + +class _TargetSpecLike: + """Adaptateur dict → objet pour `TargetMemoryStore._hash_target_spec()`. + + Le hash interne de TargetMemoryStore utilise `getattr(spec, "by_role", ...)` + qui ne fonctionne pas avec un dict brut. On expose les attributs nécessaires. + + On intègre aussi `resolve_order` et `vlm_description` dans `context_hints` + pour qu'ils entrent dans le hash — deux actions avec le même `by_text` + mais un `resolve_order` différent doivent avoir des hashes distincts. + """ + + __slots__ = ("by_role", "by_text", "by_position", "context_hints") + + def __init__(self, d: Dict[str, Any]): + self.by_role = d.get("by_role", "") or "" + self.by_text = d.get("by_text", "") or "" + self.by_position = d.get("by_position") + + hints = dict(d.get("context_hints") or {}) + resolve_order = d.get("resolve_order") + if resolve_order: + hints["_resolve_order"] = "|".join(resolve_order) if isinstance( + resolve_order, list + ) else str(resolve_order) + if d.get("vlm_description"): + hints["_vlm_desc"] = str(d["vlm_description"]) + if d.get("anchor_hint"): + hints["_anchor_hint"] = str(d["anchor_hint"]) + self.context_hints = hints + + +# ========================================================================= +# Lookup — consulté AVANT la cascade coûteuse +# ========================================================================= + + +def memory_lookup( + window_title: str, + target_spec: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Cherche une résolution apprise pour cette cible sur cet écran. + + Returns: + Dict compatible avec le format de sortie de `_resolve_target_sync` + (resolved, method, x_pct, y_pct, score, ...) si une entrée fiable + est trouvée. None sinon. + """ + store = get_memory_store() + if store is None: + return None + + screen_sig = compute_screen_sig(window_title) + if not screen_sig: + return None + + try: + spec_shim = _TargetSpecLike(target_spec) + fp = store.lookup(screen_sig, spec_shim) + except Exception as exc: + logger.debug("memory_lookup: erreur lookup (%s)", exc) + return None + + if fp is None: + return None + + # Fingerprint léger : bbox = (x_pct, y_pct, 0, 0) + try: + x_pct = float(fp.bbox[0]) + y_pct = float(fp.bbox[1]) + except (TypeError, IndexError, ValueError): + logger.debug("memory_lookup: fingerprint bbox invalide") + return None + + # Sanity check : les pourcentages doivent être dans [0, 1] + if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0): + logger.warning( + "memory_lookup: coords invalides (%.3f, %.3f) pour sig=%s — " + "entrée ignorée", + x_pct, y_pct, screen_sig, + ) + return None + + method = fp.etype or "memory" + confidence = float(getattr(fp, "confidence", 0.9) or 0.9) + + logger.info( + "memory_lookup HIT : sig=%s method=%s coords=(%.4f, %.4f) conf=%.2f " + "target='%s'", + screen_sig, method, x_pct, y_pct, confidence, + (target_spec.get("by_text") or "")[:60], + ) + + return { + "resolved": True, + "method": f"memory_{method}", + "x_pct": x_pct, + "y_pct": y_pct, + "score": confidence, + "from_memory": True, + "screen_sig": screen_sig, + } + + +# ========================================================================= +# Record — appelé APRÈS validation post-condition +# ========================================================================= + + +def memory_record_success( + window_title: str, + target_spec: Dict[str, Any], + x_pct: float, + y_pct: float, + method: str, + confidence: float = 0.9, +) -> bool: + """Enregistre une résolution réussie dans la mémoire persistante. + + À appeler APRÈS validation de la post-condition (`title_match` strict). + """ + store = get_memory_store() + if store is None: + return False + + screen_sig = compute_screen_sig(window_title) + if not screen_sig: + return False + + # Sanity check : coordonnées dans [0, 1] + try: + x_pct = float(x_pct) + y_pct = float(y_pct) + except (TypeError, ValueError): + logger.debug("memory_record_success: coords non numériques, skip") + return False + if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0): + logger.debug( + "memory_record_success: coords hors [0,1] (%.3f, %.3f), skip", + x_pct, y_pct, + ) + return False + + try: + from core.learning.target_memory_store import TargetFingerprint + + method_clean = method or "v4_unknown" + fingerprint = TargetFingerprint( + element_id=f"v4_{method_clean}", + bbox=(x_pct, y_pct, 0.0, 0.0), + role=target_spec.get("by_role", "") or None, + etype=method_clean, + label=(target_spec.get("by_text") or "")[:200] or None, + confidence=float(confidence), + ) + + spec_shim = _TargetSpecLike(target_spec) + store.record_success( + screen_signature=screen_sig, + target_spec=spec_shim, + fingerprint=fingerprint, + strategy_used=method_clean, + confidence=float(confidence), + ) + + logger.info( + "memory_record_success: sig=%s method=%s coords=(%.4f, %.4f) " + "target='%s'", + screen_sig, method_clean, x_pct, y_pct, + (target_spec.get("by_text") or "")[:60], + ) + return True + + except Exception as exc: + logger.warning("memory_record_success: échec (%s)", exc) + return False + + +def memory_record_failure( + window_title: str, + target_spec: Dict[str, Any], + error_message: str, +) -> bool: + """Incrémente le `fail_count` pour cette (signature, target). + + Appelé quand l'action échoue OU quand la post-condition n'est pas + satisfaite. Le `TargetMemoryStore.lookup()` ignorera cette entrée + si le ratio d'échecs dépasse 30%. + """ + store = get_memory_store() + if store is None: + return False + + screen_sig = compute_screen_sig(window_title) + if not screen_sig: + return False + + try: + spec_shim = _TargetSpecLike(target_spec) + store.record_failure( + screen_signature=screen_sig, + target_spec=spec_shim, + error_message=(error_message or "unknown")[:200], + ) + logger.debug( + "memory_record_failure: sig=%s error='%s'", + screen_sig, (error_message or "")[:80], + ) + return True + except Exception as exc: + logger.debug("memory_record_failure: échec (%s)", exc) + return False diff --git a/agent_v0/server_v1/resolve_engine.py b/agent_v0/server_v1/resolve_engine.py index c566d8351..ffe884962 100644 --- a/agent_v0/server_v1/resolve_engine.py +++ b/agent_v0/server_v1/resolve_engine.py @@ -1558,6 +1558,36 @@ def _resolve_target_sync( """ anchor_image_b64 = target_spec.get("anchor_image_base64", "") + # =================================================================== + # PHASE 1 APPRENTISSAGE : Lookup mémoire persistante (Fiche #18) + # =================================================================== + # Avant TOUTE résolution coûteuse (OCR/template/VLM), on consulte la + # mémoire persistante (TargetMemoryStore). Si cette cible a été résolue + # avec succès ≥2 fois sur cet écran (fail_ratio < 30%), on retourne + # directement les coordonnées mémorisées. + # + # Hit mémoire : <10ms (vs 300ms-15s de résolution) + # Miss mémoire : aucun overhead, on continue la cascade normale + # + # Les coords stockées sont celles qui ont PASSÉ la post-condition + # (title_match strict) lors des replays précédents. C'est la + # cristallisation par répétition : Léa = stagiaire qui apprend. + try: + from .replay_memory import memory_lookup + + _window_title = target_spec.get("window_title", "") or "" + if _window_title: + _mem_result = memory_lookup( + window_title=_window_title, + target_spec=target_spec, + ) + if _mem_result: + # Hit mémoire : on skip toute la cascade. + # Les coordonnées sont sanity-checked dans memory_lookup(). + return _mem_result + except Exception as _exc: + logger.debug("Memory lookup skipped : %s", _exc) + # =================================================================== # V4 : Résolution pilotée par le plan pré-compilé # =================================================================== diff --git a/docs/PLAN_APPRENTISSAGE_LEA.md b/docs/PLAN_APPRENTISSAGE_LEA.md new file mode 100644 index 000000000..b79827e45 --- /dev/null +++ b/docs/PLAN_APPRENTISSAGE_LEA.md @@ -0,0 +1,220 @@ +# Plan Apprentissage Léa — Phase 1 / 2 / 3 + +**Date** : 10 avril 2026 +**Auteur** : Dom + Claude (session cartographie target_resolver) +**Statut** : Plan validé par Dom, implémentation non commencée + +--- + +## Contexte + +Après deux semaines à debugger le replay sur Windows et avoir écrit du code (V4 : surface_classifier, UIA, execution_plan, executor strict) qui **dupliquait sans le savoir** des concepts déjà présents dans le V3 legacy, une cartographie exhaustive a été lancée. + +Fichiers lus en profondeur : +- `core/execution/target_resolver.py` (3495 lignes) +- `core/learning/target_memory_store.py` (545 lignes — Fiche #18) +- `core/models/workflow_graph.py` (TargetSpec — 570-640) +- `core/detection/spatial_analyzer.py` (595 lignes) + +## Découverte critique + +**Les pipelines V3 et V4 sont complètement découplés au runtime de replay.** + +``` +REPLAY V4 (actif aujourd'hui) LEGACY V3 (dormant au replay) +============================= ============================= +stream_processor workflow_pipeline + ↓ ↓ +execution_plan_runner execution_loop + ↓ ↓ +agent_v1/core/executor.py action_executor + ↓ ↓ +OCR + template + VLM direct target_resolver + ↓ + target_memory_store (Fiche #18) + ↓ + SpatialAnalyzer +``` + +Vérifié par `grep "from core.execution" agent_v0/` → **zéro import**. + +Callers V3 encore vivants (mais pas sur le chemin de replay critique) : +- `agent_chat/app.py` +- `visual_workflow_builder/backend/api/workflows.py` +- `core/evaluation/*` + +## Modules dormants à valeur immédiate + +### TargetMemoryStore — le Crystallizer qu'on pensait devoir écrire + +- SQLite `data/learning/target_memory.db` + JSONL audit `data/learning/events/YYYY-MM-DD/*.jsonl` +- API propre et testée : + - `record_success(screen_sig, target_spec, fingerprint, strategy, confidence)` + - `record_failure(screen_sig, target_spec, error)` + - `lookup(screen_sig, target_spec, min_success_count=2, max_fail_ratio=0.3)` → fingerprint ou None +- Clé unique : `(screen_signature, target_spec_hash)` +- Fingerprint : `(element_id, bbox, role, etype, label, confidence)` +- **Critère de fiabilité** : au moins 2 succès et < 30% d'échecs → c'est ça la "cristallisation par répétition" + +### TargetSpec — vocabulaire déjà riche + +Dans `core/models/workflow_graph.py:572` : +- `context_hints` : `near_text`, `below_text`, `right_of_text`, `same_row_as_text`, `within_region`, `exclude_near_text` +- `hard_constraints` : `within_container_text`, `min_area` +- `weights` : `proximity`, `alignment`, `container`, `roi_iou` + +### ResolutionStrategy V4 — vocabulaire pauvre (à enrichir) + +Dans `core/workflow/execution_plan.py:27` : +- `target_text`, `anchor_b64`, `zone`, `vlm_description`, `uia_*`, `dom_*` +- Pas de context_hints, pas de hard_constraints → trou dans l'expressivité + +## Décision validée + +**Léa = stagiaire qui apprend de la répétition.** La mémoire précède la généralisation. Mais le raisonnement spatial reste indispensable comme filet de sécurité quand la mémoire ne suffit pas (décalages de layout, premier replay sur nouvel écran, généralisation entre écrans similaires). + +## Plan séquencé + +### Phase 1 — Mémoire sur V4 (≈1 jour, ~150 lignes) + +**Objectif** : greffer `TargetMemoryStore` directement sur le resolve V4, sans passer par target_resolver ni UIElement. + +**Lookup avant OCR/template/VLM** +```python +fp = memory.lookup(screen_sig, target_spec) +if fp: + # On a vu ce clic réussir ≥2 fois sur cet écran + return fp.bbox # clic direct, <10ms +``` + +**Record après validation post-condition (déjà en place — `title_match` strict)** +```python +if post_condition_passed: + memory.record_success(screen_sig, target_spec, fingerprint, "v4_ocr", confidence) +else: + memory.record_failure(screen_sig, target_spec, reason) +``` + +**À construire** +- `screen_signature(screenshot)` → hash stable. Piste : `window_title` + tokens OCR dominants, ou réutiliser `core/execution/screen_signature.py` si compatible. +- Fingerprint léger : `(x, y, w, h, method)`. Pas besoin de role/type/label en V4. +- Point de branchement exact à confirmer avant implémentation : + - Côté serveur dans `resolve_engine` (si resolve serveur) + - Côté agent dans `agent_v1/core/executor.py` (si resolve local) + +**Bénéfice observable** +- 3ème passage d'un workflow sur même écran : 10-15s VLM remplacés par <10ms lookup +- Léa **apprend** vraiment — pas parce qu'on a écrit un Crystallizer, parce qu'on a consommé celui qui dort depuis mars + +**Tests de validation** +- [ ] Rejouer un workflow 3 fois, mesurer le temps du 3ème passage +- [ ] Vérifier que `data/learning/target_memory.db` se remplit +- [ ] Vérifier que les événements JSONL s'écrivent + +### Phase 2 light — Raisonnement spatial OCR-only (≈3-5 jours, ~300-400 lignes) + +**Principe clé** : pur pixel/OCR. Pas d'`UIElement`, pas de role/type, pas de parser UI. On évite le piège "ressusciter V3 complet". + +**À l'enregistrement (IRBuilder, côté serveur)** +1. Pour chaque clic `(x, y)` dans la trace +2. OCR la zone autour (±300px) +3. Identifier les 3-5 textes les plus proches avec direction (left/right/above/below) et distance +4. Populer `ResolutionStrategy.context_hints` : + ```python + { + "right_of_text": "Nom du patient", # 60px à gauche du clic + "below_text": "Identité", # 120px au-dessus + "near_text": "Enregistrer", # le texte du clic lui-même + } + ``` + +**Au replay (resolve_engine)**, en cascade : +1. Lookup mémoire (Phase 1) → si hit, clic direct +2. Sinon : OCR de l'écran actuel +3. Trouver les ancres de `context_hints` via OCR (normalisation accents + fuzzy Fiche #8) +4. Calculer la zone candidate par intersection des contraintes spatiales +5. Cliquer +6. Si post-cond échoue : retombée VLM (exception handler) + +**Logique à porter depuis target_resolver.py** +- `_apply_context_hints_to_candidates` (lignes 2601-2803) — adaptée à "candidats = zones OCR" au lieu de "candidats = UIElement" +- `_find_element_by_text` + normalisation (`_norm_text`, `_fuzzy_ratio`) lignes 211-235 +- Healing profile (ligne 395) pour relaxation progressive + +**Décision tranchée** +- OCR **côté serveur Linux** (docTR déjà présent via SomEngine) +- Zéro changement sur le client Windows +- Le serveur reçoit le screenshot au moment du build IR, extrait les context_hints, les intègre dans `ResolutionStrategy` + +**Enrichissement de `ResolutionStrategy` (execution_plan.py)** +Ajouter au dataclass : +```python +context_hints: Dict[str, Any] = field(default_factory=dict) +``` + +Et dans `execution_plan_runner._strategy_to_target_spec` : propager `context_hints` dans `target_spec`. + +**Tests de validation** +- [ ] Enregistrer un workflow, vérifier que le plan contient des `context_hints` cohérents +- [ ] Modifier la résolution de la VM (1920→1280), rejouer, vérifier que les clics atteignent la bonne cible +- [ ] Ajouter un champ au-dessus de la cible, rejouer, vérifier robustesse + +### Phase 3 — Spatial V3 complet (pas maintenant) + +**Correction 10 avril 2026** : une version précédente de ce document affirmait qu'OmniParser avait été retiré. **C'était faux.** OmniParser est toujours présent : +- `core/detection/omniparser_adapter.py` — 429 lignes +- `agent_v0/server_v1/resolve_engine.py:254` — `_get_omniparser()` singleton thread-safe, lazy-load +- `agent_v0/server_v1/resolve_engine.py:293` — `_resolve_by_yolo()` défini et importé dans `api_stream.py` + +Ce qui est vrai : `_resolve_by_yolo` **n'est jamais appelé** dans la cascade V4 (`_resolve_target_sync` ne l'invoque pas). C'est du code **dormant**, pas supprimé. + +**Conséquence pour Phase 3** : on a potentiellement **déjà** un parser UI utilisable. Deux pistes : +1. **Ré-activer `_resolve_by_yolo`** dans la cascade V4 (injecter un appel dans `_resolve_target_sync` comme fallback après OCR/template/VLM). Il produit déjà une liste d'éléments détectés avec bbox et role approximatif. +2. **Pont `_resolve_by_yolo → List[UIElement]`** : adapter la sortie YOLO pour alimenter `target_resolver` V3. Un pont d'une centaine de lignes devrait suffire. + +**Avant de lancer Phase 3**, vérifier : +- Les modèles YOLO sont-ils toujours sur disque ? (`omniparser.detect()` lazy-loads) +- Quelle qualité de détection sur des écrans Citrix/DPI réels ? +- Les tests `tests/integration/test_auto_healing_integration.py` et `tests/unit/test_fiche11_*` passent-ils encore ? + +**Tant qu'on n'a pas fait cette vérification, Phase 3 reste pending.** + +## Ce qu'on ne fait PAS + +| Tentation | Pourquoi on résiste | +|-----------|---------------------| +| Refactorer `target_resolver.py` pour le rendre V4-compatible | 3495 lignes couplées à `UIElement` disparu — plus économique de le laisser dormir et recoder l'essentiel minimal dans V4 | +| Brancher `action_executor` sur le streaming replay | 2000 lignes de pipeline pour un bénéfice qu'on a en 150 lignes avec TargetMemoryStore seul | +| Ressusciter `SpatialAnalyzer` maintenant | Zéro valeur sans `UIElement` riches en amont | +| Faire Phase 2 avant Phase 1 | Léa raisonnerait à chaque clic, lent et coûteux — pas un "stagiaire qui apprend", juste un agent qui réfléchit en boucle | + +## Suivi d'avancement + +### Phase 1 — Mémoire sur V4 +- [ ] Identifier le point de branchement exact (serveur vs agent) +- [ ] Définir `screen_signature` stable pour V4 +- [ ] Définir le format fingerprint léger +- [ ] Brancher `memory.lookup()` avant cascade OCR/template/VLM +- [ ] Brancher `memory.record_success()` après post-cond validée +- [ ] Brancher `memory.record_failure()` sur échec +- [ ] Test : workflow rejoué 3 fois, 3ème en <100ms sur le resolve +- [ ] Vérifier remplissage de `data/learning/target_memory.db` + +### Phase 2 light — Spatial OCR-only +- [ ] Enrichir `ResolutionStrategy` avec `context_hints` +- [ ] IRBuilder : extraire context_hints via OCR au build +- [ ] `execution_plan_runner` : propager context_hints dans target_spec +- [ ] resolve_engine : implémenter fallback spatial OCR +- [ ] Porter `_apply_context_hints_to_candidates` adapté +- [ ] Porter normalisation texte (`_norm_text`, `_fuzzy_ratio`) +- [ ] Test : résolution VM modifiée, clic atteint toujours la cible +- [ ] Test : champ ajouté dans le formulaire, robustesse préservée + +### Phase 3 — Spatial V3 complet +- [ ] **BLOQUÉ** jusqu'à ce qu'un parser UI produise des `UIElement` + +## Liens + +- Code de référence : `core/execution/target_resolver.py`, `core/learning/target_memory_store.py` +- Architecture V4 : `core/workflow/execution_plan.py`, `core/workflow/execution_compiler.py`, `agent_v0/server_v1/execution_plan_runner.py` +- Replay runtime : `agent_v0/agent_v1/core/executor.py`