From a21f1ea9fa3eb04ecc6b187e5efbe42b32b32648 Mon Sep 17 00:00:00 2001 From: Dom Date: Sat, 11 Apr 2026 09:11:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20garde=20qualit=C3=A9=20r=C3=A9solution?= =?UTF-8?q?=20(B)=20+=20no=5Fscreen=5Fchange=20strict=20(C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux garde-fous qui ferment des trous identifiés lors du test de replay chirurgical du 11 avril 2026 sur sess_20260411T084629_2d588e. ## B — Garde qualité en sortie de cascade (_validate_resolution_quality) Couche de validation ajoutée en sortie du handler /resolve_target, après que la cascade (_resolve_target_sync) a produit son meilleur candidat. Single point of insertion, n'altère pas la cascade existante. Deux checks : 1. Seuil de score minimum par méthode (_RESOLUTION_MIN_SCORES) - hybrid_text_direct ≥ 0.80 - som_anchor_match / som_text_match ≥ 0.75 - template_matching ≥ 0.85 - vlm_* / grounding ≥ 0.60 - memory_* : pas de seuil (confiance cristallisée) - v4_uia_local / uia ≥ 0.90 2. Garde de proximité contre coords enregistrées Si fallback_x/y_pct sont significatifs (pas placeholder 0.5/0.5 ni 0.0/0.0), rejette si drift > 20% de l'écran dans un axe. Reproduit un faux positif vu en production : SoM a trouvé "Enregistrer" à (0.505, 0.770) alors que l'enregistrement était à (0.093, 0.356) — écart de 0.41. Quand un check rejette : retourne resolved=False avec method= "rejected_low_score_*" ou "rejected_drift_*" et reason détaillée. L'action passe alors par le chemin "visual_resolve_failed" côté agent → Policy → pause supervisée ou retry selon contexte. 7 tests unitaires inline validés (score bas, drift, mémoire qui passe toujours, placeholders V4 qui skip la garde drift, etc.). ## C — no_screen_change devient un échec strict en mode strict Avant : si un clic retourne warning='no_screen_change' (écran inchangé après action), le replay loggait un warning et CONTINUAIT à l'action suivante. Trop indulgent pour les workflows critiques. Maintenant : la branche no_screen_change consulte le flag success_strict de l'action courante. - success_strict=True : traité comme vrai échec → retry si retry_count < MAX_RETRIES_PER_ACTION → stop définitif sinon (status=error, queue vidée, callback) - success_strict=False (legacy) : comportement inchangé, on continue Prérequis : _create_replay_state copie maintenant success_strict, expected_window_before, expected_window_title, intention dans la version slim de actions stockée dans replay_state. Nécessaire pour lire le flag depuis current_action_index dans /replay/result. ## Tests - 7 tests unitaires inline sur _validate_resolution_quality - 56 tests E2E + Phase0 passent, zéro régression - Instrumentation [REPLAY] reste pleinement fonctionnelle ## Limites non traitées ici (explicites) - La latence de 14s entre deux clics (pre-analyze + cascade + agent polling) reste inchangée. Les menus déroulants Windows peuvent encore se refermer avant le 2ème clic. Piste A du plan, à traiter séparément. - L'intégration d'OS-Atlas-Base-7B comme grounder spécialisé reste dans les cartons (recommandation du rapport état de l'art). Co-Authored-By: Claude Opus 4.6 (1M context) --- agent_v0/server_v1/api_stream.py | 80 ++++++++++++-- agent_v0/server_v1/replay_engine.py | 10 ++ agent_v0/server_v1/resolve_engine.py | 157 +++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 10 deletions(-) diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index c3a34e52c..403b4cdda 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -3172,15 +3172,64 @@ async def report_action_result(report: ReplayResultReport): elif not report.success and agent_warning == "no_screen_change": # L'action a été exécutée mais l'écran n'a pas changé. - # PAS de retry — loguer l'échec et continuer vers l'action suivante. - # C'est plus honnête que "success" et évite les retries en boucle. - replay_state["unverified_actions"] += 1 - replay_state["completed_actions"] += 1 - replay_state["current_action_index"] += 1 - logger.warning( - f"Action {action_id} : écran inchangé (no_screen_change) — " - f"action sans effet visible, on continue" - ) + # + # Comportement legacy (success_strict=False) : loguer l'échec + # et continuer vers l'action suivante. Justifié pour les + # workflows tolérants où un clic "sans effet" peut être normal + # (ex: cliquer sur une case déjà cochée). + # + # Comportement strict (success_strict=True) : écran inchangé = + # vraie erreur. On déclenche un retry, puis stop après épuisement. + # Justifié pour les workflows critiques où chaque action doit + # produire un effet visuel observable. + _is_strict = False + _idx_strict = replay_state.get("current_action_index", 0) + _actions_meta_strict = replay_state.get("actions", []) + if 0 <= _idx_strict < len(_actions_meta_strict): + _current_strict = _actions_meta_strict[_idx_strict] or {} + _is_strict = bool(_current_strict.get("success_strict", False)) + + if _is_strict and retry_count < MAX_RETRIES_PER_ACTION: + # Strict + retries restants : on réinjecte l'action + logger.warning( + f"Action {action_id} STRICT : écran inchangé — " + f"retry {retry_count + 1}/{MAX_RETRIES_PER_ACTION}" + ) + _schedule_retry( + session_id, replay_state, + original_action or {"action_id": action_id}, + retry_count, "no_screen_change_strict", + ) + elif _is_strict: + # Strict + retries épuisés : échec définitif, stop replay + replay_state["failed_actions"] += 1 + replay_state["unverified_actions"] += 1 + error_entry = { + "action_id": action_id, + "error": "no_screen_change after strict retries", + "retry_count": retry_count, + "timestamp": time.time(), + } + replay_state["error_log"].append(error_entry) + replay_state["status"] = "error" + _replay_queues[session_id] = [] + logger.error( + f"Replay {replay_state['replay_id']} STRICT échoué : " + f"action {action_id} écran inchangé après {retry_count} retries" + ) + _notify_error_callback( + replay_state, action_id, + "no_screen_change after strict retries", + ) + else: + # Legacy : on continue + replay_state["unverified_actions"] += 1 + replay_state["completed_actions"] += 1 + replay_state["current_action_index"] += 1 + logger.warning( + f"Action {action_id} : écran inchangé (no_screen_change) — " + f"action sans effet visible, on continue (non-strict)" + ) elif not report.success and (report.error or "") == "target_not_found": # Cible non trouvée visuellement — PAUSE supervisée, PAS d'erreur fatale. @@ -3512,6 +3561,7 @@ from .resolve_engine import ( _get_som_engine_api, _resolve_by_som, _resolve_target_sync, + _validate_resolution_quality, _fuzzy_match, _fallback_response, _pre_analyze_screen_sync, @@ -3570,7 +3620,17 @@ async def resolve_target(request: ResolveTargetRequest): request.strict_mode, processor, ) - # [REPLAY] log structuré de sortie résolution + + # Validation qualité en sortie de cascade : seuil de score + garde + # de proximité contre les coords enregistrées. Single point of + # insertion, n'altère pas la cascade existante. + result = _validate_resolution_quality( + result, + request.fallback_x_pct, + request.fallback_y_pct, + ) + + # [REPLAY] log structuré de sortie résolution (après validation) logger.info( f"[REPLAY] RESOLVE_EXIT session={request.session_id} " f"resolved={result.get('resolved', False) if result else False} " diff --git a/agent_v0/server_v1/replay_engine.py b/agent_v0/server_v1/replay_engine.py index 5cdb09af6..c745b4b82 100644 --- a/agent_v0/server_v1/replay_engine.py +++ b/agent_v0/server_v1/replay_engine.py @@ -1159,6 +1159,10 @@ def _create_replay_state( """ # Copie slim des actions : on strip les anchor_image_base64 pour ne # pas gonfler la mémoire (anchors peuvent faire 50-200 KB chacun). + # On conserve les champs utilisés par : + # - la Phase 1 apprentissage (target_spec pour memory_record_success) + # - le contrôle strict (success_strict, expected_window_*) + # - les logs/audit (intention, action_id, type, coords) actions_slim: List[Dict[str, Any]] = [] if actions: for a in actions: @@ -1167,6 +1171,12 @@ def _create_replay_state( "type": a.get("type"), "x_pct": a.get("x_pct"), "y_pct": a.get("y_pct"), + # Contrôle strict des étapes (Dom, matin 10 avril 2026) + "success_strict": a.get("success_strict", False), + "expected_window_before": a.get("expected_window_before", ""), + "expected_window_title": a.get("expected_window_title", ""), + # Contexte métier utile pour logs et apprentissage + "intention": a.get("intention", ""), } ts = a.get("target_spec") if isinstance(ts, dict): diff --git a/agent_v0/server_v1/resolve_engine.py b/agent_v0/server_v1/resolve_engine.py index e15cab95e..28d4389a0 100644 --- a/agent_v0/server_v1/resolve_engine.py +++ b/agent_v0/server_v1/resolve_engine.py @@ -2060,6 +2060,163 @@ def _fallback_response(request: ResolveTargetRequest, reason: str, detail: str) } +# ========================================================================= +# Validation qualité de résolution (garde score + garde proximité) +# ========================================================================= +# +# Couche de sécurité appliquée en sortie de la cascade de résolution pour +# rejeter les résultats peu fiables. Deux checks : +# +# 1. Seuil de score minimum par méthode — chaque stratégie a sa propre +# fiabilité empirique. Un SoM+VLM à 0.59 n'a pas le même sens qu'un +# hybrid_text_direct à 0.59. +# +# 2. Garde de proximité — si les coordonnées enregistrées lors de la +# démonstration sont disponibles (via fallback_x/y_pct), on rejette +# tout résultat dont les coordonnées résolues sont aberrantes par +# rapport aux coordonnées attendues. Un écart > 20% de l'écran +# signale un faux positif (ex: SoM qui a trouvé le même texte +# "Enregistrer" à un endroit totalement différent). +# +# Insertion : appelée une fois, juste avant de retourner le résultat au +# client dans le handler /resolve_target. N'altère pas la cascade existante. + +# Seuils minimum de score par méthode. Les méthodes non listées héritent +# du seuil par défaut (0.5). Une méthode peut être matchée par préfixe +# (ex: "memory_v4_ocr" match "memory_"). +_RESOLUTION_MIN_SCORES: Dict[str, float] = { + # Mémoire Phase 1 : si une entrée a été cristallisée (≥2 succès), on a + # une confiance quasi absolue — pas de seuil strict. + "memory_": 0.0, + # hybrid_text_direct est un matching OCR direct : fiable quand il trouve. + "hybrid_text_direct": 0.80, + # SoM (Set-of-Mark) : peut retourner un faux positif si l'élément + # cherché existe à plusieurs endroits de l'écran. + "som_anchor_match": 0.75, + "som_text_match": 0.75, + "som_vlm": 0.70, + # Template matching : très strict sur la ressemblance pixel. + "template_matching": 0.85, + "v4_template": 0.85, + # OCR seul (V4 ExecutionPlan) : fiable avec score moyen. + "v4_ocr": 0.70, + # VLM : souvent moins précis sur les coordonnées, seuil plus souple. + "vlm_quick_find": 0.60, + "vlm": 0.60, + "v4_vlm": 0.60, + "grounding": 0.60, + "v4_grounding": 0.60, + # UIA local : déterministe, confiance élevée quand succès. + "v4_uia_local": 0.90, + "uia": 0.90, +} + +# Écart maximum toléré entre coords résolues et coords enregistrées +# (en fraction d'écran, dans chaque axe). Au-delà, on considère que la +# résolution a trouvé un faux positif ailleurs sur l'écran. +_RESOLUTION_MAX_DRIFT: float = 0.20 + + +def _validate_resolution_quality( + result: Optional[Dict[str, Any]], + fallback_x_pct: float, + fallback_y_pct: float, +) -> Optional[Dict[str, Any]]: + """Valide un résultat de résolution et le rejette s'il est peu fiable. + + Deux checks appliqués en sortie de cascade : + - Score minimum par méthode (voir _RESOLUTION_MIN_SCORES) + - Drift maximum par rapport aux coordonnées enregistrées + (_RESOLUTION_MAX_DRIFT, activé uniquement si fallback_x/y_pct + sont significatifs, c'est-à-dire différents du placeholder 0.5/0.5 + et non nuls). + + Si un check échoue, retourne un nouveau dict `resolved=False` avec + une raison explicite. Sinon retourne le result inchangé. + + Cette fonction est le **seul point d'insertion** des gardes qualité : + elle n'est PAS appelée par les méthodes internes de la cascade, mais + uniquement depuis le handler HTTP `/resolve_target` après que la + cascade a produit son meilleur candidat. + """ + if not result or not isinstance(result, dict): + return result + if not result.get("resolved"): + return result + + method = str(result.get("method", "") or "") + try: + score = float(result.get("score", 0) or 0) + except (TypeError, ValueError): + score = 0.0 + try: + resolved_x = float(result.get("x_pct", 0) or 0) + resolved_y = float(result.get("y_pct", 0) or 0) + except (TypeError, ValueError): + return result # coords non-numériques : on ne peut rien valider + + # --- Check 1 : seuil de score par méthode --- + # Trouver le seuil qui s'applique (match exact ou préfixe) + min_score: Optional[float] = None + if method in _RESOLUTION_MIN_SCORES: + min_score = _RESOLUTION_MIN_SCORES[method] + else: + for prefix, threshold in _RESOLUTION_MIN_SCORES.items(): + if prefix.endswith("_") and method.startswith(prefix): + min_score = threshold + break + + if min_score is not None and score < min_score: + logger.warning( + "[REPLAY] Resolution REJETÉE (score trop bas) : method=%s score=%.3f < %.2f", + method, score, min_score, + ) + return { + "resolved": False, + "method": f"rejected_low_score_{method}", + "reason": f"score_{score:.3f}_below_threshold_{min_score:.2f}", + "original_method": method, + "original_score": score, + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + # --- Check 2 : garde de proximité --- + # On n'applique la garde que si les coordonnées enregistrées ont un + # sens (pas des placeholders 0.5/0.5 des plans V4 ni des 0.0/0.0). + _has_recorded_coords = ( + fallback_x_pct > 0.001 + and fallback_y_pct > 0.001 + and not (abs(fallback_x_pct - 0.5) < 0.001 and abs(fallback_y_pct - 0.5) < 0.001) + ) + if _has_recorded_coords: + dx = abs(resolved_x - fallback_x_pct) + dy = abs(resolved_y - fallback_y_pct) + if dx > _RESOLUTION_MAX_DRIFT or dy > _RESOLUTION_MAX_DRIFT: + logger.warning( + "[REPLAY] Resolution REJETÉE (drift trop grand) : " + "method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f) " + "drift=(%.3f, %.3f) max=%.2f", + method, resolved_x, resolved_y, + fallback_x_pct, fallback_y_pct, + dx, dy, _RESOLUTION_MAX_DRIFT, + ) + return { + "resolved": False, + "method": f"rejected_drift_{method}", + "reason": f"drift_dx{dx:.3f}_dy{dy:.3f}_max{_RESOLUTION_MAX_DRIFT:.2f}", + "original_method": method, + "original_score": score, + "drift_dx": round(dx, 3), + "drift_dy": round(dy, 3), + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + # Validation OK — on retourne le result inchangé + return result + + # ========================================================================= # Observer — Pré-analyse écran avant résolution # =========================================================================