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":
|
||||
# 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} "
|
||||
|
||||
Reference in New Issue
Block a user