Files
rpa_vision_v3/agent_v0/server_v1/replay_memory.py
Dom 93ef93e563 feat(security): API streaming fail-closed + /image privé + target_memory prefix fix
P0-B — /api/v1/traces/stream/image retiré de _PUBLIC_PATHS :
- Bearer token obligatoire pour upload d'image
- Évite uploads anonymes de contenu arbitraire

P0-C — Fail-closed si RPA_API_TOKEN absent :
- sys.exit(1) au démarrage avec message fatal
- Mode dev : RPA_AUTH_DISABLED=true pour désactiver explicitement
- Log INFO des 8 premiers chars du token (diagnostic)

Fix target_memory prefix empilé :
- Strip "memory_" répétés avant stockage dans replay_memory.py
- Évite "memory_memory_memory_template_matching" en base

live_session_manager : améliorations mineures de la gestion sessions.

10 tests auth API stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:49:02 +02:00

324 lines
11 KiB
Python

# 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
# Stripper les préfixes "memory_" empilés pour ne garder que
# la méthode de résolution originale (ex: template_matching).
# Sans ça, le cycle lookup → record → lookup empile "memory_"
# indéfiniment : memory_memory_memory_template_matching.
method_clean = method or "v4_unknown"
while method_clean.startswith("memory_"):
method_clean = method_clean[len("memory_"):]
method_clean = method_clean 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