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:
@@ -3172,15 +3172,64 @@ async def report_action_result(report: ReplayResultReport):
|
|||||||
|
|
||||||
elif not report.success and agent_warning == "no_screen_change":
|
elif not report.success and agent_warning == "no_screen_change":
|
||||||
# L'action a été exécutée mais l'écran n'a pas changé.
|
# 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.
|
# Comportement legacy (success_strict=False) : loguer l'échec
|
||||||
replay_state["unverified_actions"] += 1
|
# et continuer vers l'action suivante. Justifié pour les
|
||||||
replay_state["completed_actions"] += 1
|
# workflows tolérants où un clic "sans effet" peut être normal
|
||||||
replay_state["current_action_index"] += 1
|
# (ex: cliquer sur une case déjà cochée).
|
||||||
logger.warning(
|
#
|
||||||
f"Action {action_id} : écran inchangé (no_screen_change) — "
|
# Comportement strict (success_strict=True) : écran inchangé =
|
||||||
f"action sans effet visible, on continue"
|
# 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":
|
elif not report.success and (report.error or "") == "target_not_found":
|
||||||
# Cible non trouvée visuellement — PAUSE supervisée, PAS d'erreur fatale.
|
# Cible non trouvée visuellement — PAUSE supervisée, PAS d'erreur fatale.
|
||||||
@@ -3512,6 +3561,7 @@ from .resolve_engine import (
|
|||||||
_get_som_engine_api,
|
_get_som_engine_api,
|
||||||
_resolve_by_som,
|
_resolve_by_som,
|
||||||
_resolve_target_sync,
|
_resolve_target_sync,
|
||||||
|
_validate_resolution_quality,
|
||||||
_fuzzy_match,
|
_fuzzy_match,
|
||||||
_fallback_response,
|
_fallback_response,
|
||||||
_pre_analyze_screen_sync,
|
_pre_analyze_screen_sync,
|
||||||
@@ -3570,7 +3620,17 @@ async def resolve_target(request: ResolveTargetRequest):
|
|||||||
request.strict_mode,
|
request.strict_mode,
|
||||||
processor,
|
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(
|
logger.info(
|
||||||
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "
|
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "
|
||||||
f"resolved={result.get('resolved', False) if result else False} "
|
f"resolved={result.get('resolved', False) if result else False} "
|
||||||
|
|||||||
@@ -1159,6 +1159,10 @@ def _create_replay_state(
|
|||||||
"""
|
"""
|
||||||
# Copie slim des actions : on strip les anchor_image_base64 pour ne
|
# Copie slim des actions : on strip les anchor_image_base64 pour ne
|
||||||
# pas gonfler la mémoire (anchors peuvent faire 50-200 KB chacun).
|
# 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]] = []
|
actions_slim: List[Dict[str, Any]] = []
|
||||||
if actions:
|
if actions:
|
||||||
for a in actions:
|
for a in actions:
|
||||||
@@ -1167,6 +1171,12 @@ def _create_replay_state(
|
|||||||
"type": a.get("type"),
|
"type": a.get("type"),
|
||||||
"x_pct": a.get("x_pct"),
|
"x_pct": a.get("x_pct"),
|
||||||
"y_pct": a.get("y_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")
|
ts = a.get("target_spec")
|
||||||
if isinstance(ts, dict):
|
if isinstance(ts, dict):
|
||||||
|
|||||||
@@ -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
|
# Observer — Pré-analyse écran avant résolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user