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:
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
316
agent_v0/server_v1/replay_memory.py
Normal file
316
agent_v0/server_v1/replay_memory.py
Normal 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
|
||||
@@ -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é
|
||||
# ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user