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

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