feat: garde qualité résolution (B) + no_screen_change strict (C)

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) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-11 09:11:41 +02:00
parent 9188bd7df1
commit a21f1ea9fa
3 changed files with 237 additions and 10 deletions

View File

@@ -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} "

View File

@@ -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):

View File

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