fix(replay): cure régression b584bbabc — fallback recorded_coords aveugle

Trois changements complémentaires dans la cascade de résolution serveur,
finis ce soir 7 mai pour la démo GHT 8 mai. Restaure le comportement strict
d'avril 2026 (workflow qui passait 20 fois d'affilée sans incident).

1. resolve_engine.py — _validate_resolution_quality (lignes 2255-2289) :
   Le commit b584bbabc du 1er mai 2026 ("fix(stream): démo UHCD") avait
   transformé le rejet strict (resolved=False, method="rejected_drift_*")
   en fallback aveugle (resolved=True, method="fallback_recorded_coords",
   coords du record). Symptôme observé : Léa cliquait sur "Dossier en
   cours" du menu au lieu de "Synthèse Urgences" du tab — le VLM Quick
   Find Ollama hallucinait à (0.526, 0.918), drift dépassé, fallback
   ratait. Restauré : resolved=False explicite, le client passe en
   pause supervisée comme prévu (philosophie échec = apprentissage).

2. resolve_engine.py — exemption high-confidence élargie :
   L'exemption drift>0.20 IGNORÉ ne couvrait que template_matching ≥ 0.95
   (commit 35b27ae49 du 2 mai). Étendue à hybrid_text_direct ≥ 0.80 :
   un OCR direct qui trouve le texte cible exact à score 0.80+ est aussi
   sûr qu'un template à 0.95 — la position est sémantiquement vraie,
   le drift reflète juste un changement de layout (résolution écran,
   refonte UI, scroll), pas une erreur de résolution.

3. resolve_engine.py + api_stream.py — pré-check OCR sémantique :
   Nouvelle fonction _validate_text_at_position (singleton EasyOCR fr+en,
   crop 200px autour de la coord résolue, fuzzy match 60% des tokens
   ≥3 caractères de l'expected_text). Câblée dans api_stream.py juste
   après _validate_resolution_quality. Si le by_text attendu n'est PAS
   présent dans la zone autour de la coord résolue → resolved=False
   method="rejected_text_mismatch" → pause supervisée.

Pattern Verification-Aware Planning (state of the art 2026 — voir
recommandations agent archéologue + agent SOTA review) : le serveur
ne renvoie une coord que s'il est sémantiquement sûr du résultat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-07 22:03:18 +02:00
parent 7233df2bb9
commit 40440f1ca0
2 changed files with 201 additions and 17 deletions

View File

@@ -4503,6 +4503,44 @@ async def resolve_target(request: ResolveTargetRequest):
request.fallback_y_pct,
)
# Pré-check sémantique post-cascade : OCR sur une zone autour de la
# coordonnée résolue pour vérifier que le by_text attendu y est bien
# présent. Attrape les cas où la cascade rend des coords plausibles
# mais pointant sur un autre élément (ex : clic sur "Dossier en cours"
# du menu au lieu de "Synthèse Urgences" du tab plus bas).
if result and result.get("resolved"):
_by_text = (request.target_spec.get("by_text") or "").strip()
if _by_text:
from agent_v0.server_v1.resolve_engine import _validate_text_at_position
_is_valid, _observed, _ocr_ms = _validate_text_at_position(
tmp_path,
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
_by_text,
effective_w,
effective_h,
)
if not _is_valid:
logger.warning(
"[REPLAY] Pre-check OCR REJET : '%s' attendu @ (%.4f, %.4f) "
"via %s mais OCR voit '%s' (%.0fms)",
_by_text[:40],
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
result.get("method", "?"),
_observed[:80],
_ocr_ms,
)
result = {
"resolved": False,
"method": "rejected_text_mismatch",
"reason": f"expected='{_by_text[:40]}' observed='{_observed[:60]}'",
"original_method": result.get("method"),
"original_score": result.get("score"),
"x_pct": None,
"y_pct": None,
}
# [REPLAY] log structuré de sortie résolution (après validation)
logger.info(
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "