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