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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user