feat: Phase 1 apprentissage — greffe TargetMemoryStore sur V4

Greffe minimale du mécanisme d'apprentissage persistant (Fiche #18,
target_memory_store.py) sur le pipeline streaming V4 sans toucher à V3.

Architecture (docs/PLAN_APPRENTISSAGE_LEA.md) :
- Lookup mémoire AVANT la cascade résolution coûteuse OCR/template/VLM
  dans _resolve_target_sync → hit = <10ms, miss = overhead zéro
- Record APRÈS validation post-condition (title_match strict)
  dans /replay/result → 2 succès → cristallisation par répétition
- Single source of truth : l'agent remplit report.actual_position avec
  les coords effectivement cliquées, le serveur les lit directement.
  Pas de cache intermédiaire (option C du plan).

Signature écran V4 : sha256(normalize(window_title))[:16]. Robuste aux
données variables, faux positifs rattrapés par le post-cond qui
décrémente la fiabilité via record_failure().

Fichiers :
- agent_v0/server_v1/replay_memory.py : nouveau wrapper 316 lignes
  exposant compute_screen_sig/memory_lookup/record_success/failure,
  lazy-init du store, normalisation texte stable, garde sanity coords
- agent_v0/server_v1/resolve_engine.py : lookup mémoire en tête de
  _resolve_target_sync (30 lignes)
- agent_v0/server_v1/replay_engine.py : _create_replay_state stocke
  une copie slim des actions (sans anchor base64) pour retrouver le
  target_spec par current_action_index
- agent_v0/server_v1/api_stream.py : 4 callers passent actions=...,
  record success/failure dans /replay/result lit actual_position
  du rapport (click-only), correction du commentaire Pydantic
- agent_v0/agent_v1/core/executor.py : remplit result["actual_position"]
  après self._click(), transmis dans le report de poll_and_execute

Tests : 56 E2E + Phase0 passent, zéro régression. Cycle Phase 1 validé
en simulation : miss → record → miss → record → HIT au 3ème passage.

Le deploy copy executor.py a une divergence pré-existante de 1302
lignes non committées — traité séparément lors du cleanup prochain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 21:08:14 +02:00
parent e66629ce1a
commit b92cb9db03
6 changed files with 674 additions and 2 deletions

View File

@@ -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"),

View File

@@ -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):

View File

@@ -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,

View File

@@ -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

View File

@@ -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é
# ===================================================================

View File

@@ -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`