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 # =========================================================================