fix(stream): /resolve_target — fallback heartbeat full si image client tronquée

Bug client constaté ce 2026-05-07 sur PC Windows 192.168.1.11 (agent V1) :
mss.monitors[1] retourne parfois une image tronquée type 2560x60, 2560x108,
600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
ou un état mss corrompu. Reproduit même PC en mono physique. Cause exacte
non isolée côté client.

Sans cette image, _resolve_target_sync ne peut rien résoudre :
- Template matching échoue (anchor 104x31 vs image 600x72)
- OCR direct ne trouve pas la cible (texte hors de l'image tronquée)
- VLM Quick Find hallucine systématiquement la même position
- Fallback recorded_coords clique au mauvais endroit

Conséquence reproduite hier soir : "Léa clique partout au pif"
(cf. session_20260506_handoff_v2.md).

Filet de sécurité côté serveur : si l'image reçue est anormalement
tronquée (height < 200 ou width < 400), le serveur la remplace par le
dernier heartbeat full screen avant la cascade _resolve_target_sync.

Sources de fallback dans l'ordre :
1. _last_heartbeat (mémoire, peuplé par /stream/image en runtime)
2. Scan disque data/training/live_sessions/*/bg_*/shots/heartbeat_*.png
   (utile après restart serveur ou si l'agent V1 ne polle pas)

Validé en isolation : image tronquée 600x60 → fallback heartbeat 2560x1600
→ template matching score 0.999 → coords (0.0312, 0.3500) = exactement
la position de l'IPP cible '25003284' en première ligne d'Easily Assure.

Bug client à traiter post-démo. Le fallback heartbeat reste utile en
roadmap autonome (résilience aux états mss transitoires).

Note : également retiré un import os local redondant dans le finally
(masquait la variable globale et provoquait UnboundLocalError dans
le scope du bloc fallback).
This commit is contained in:
Dom
2026-05-07 09:31:07 +02:00
parent 22c0a2ba61
commit f62fda575f

View File

@@ -4400,6 +4400,69 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"Décodage screenshot échoué: {e}") logger.error(f"Décodage screenshot échoué: {e}")
return _fallback_response(request, "decode_error", str(e)) return _fallback_response(request, "decode_error", str(e))
# Détection image tronquée + fallback heartbeat full screen.
# Bug client constaté ce 2026-05-07 (PC Windows 192.168.1.11, agent V1) :
# mss.monitors[1] retourne parfois une bande étroite type 2560x60, 2560x108,
# 600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
# ou un état mss corrompu. Reproductible même PC en mono physique. Cause
# exacte non isolée côté client (cf. session_20260506_handoff_v2.md).
# Les heartbeats (capturer.py, chemin différent de executor.py) restent en
# full screen 2560x1600. On compense ici en remplaçant l'image tronquée
# par le dernier heartbeat avant la cascade _resolve_target_sync.
effective_w = request.screen_width
effective_h = request.screen_height
if img.height < 200 or img.width < 400:
logger.warning(
"[RESOLVE_TARGET] Image client tronquée %dx%d (declared %dx%d) — "
"fallback heartbeat full screen",
img.width, img.height, effective_w, effective_h,
)
# Source 1 : _last_heartbeat (mémoire, peuplé par /stream/image)
candidate_path = None
candidate_age_s = None
latest_hb = max(
(h for h in _last_heartbeat.values() if h.get("path")),
key=lambda h: h.get("timestamp", 0),
default=None,
)
if latest_hb and os.path.isfile(latest_hb["path"]):
candidate_path = latest_hb["path"]
candidate_age_s = time.time() - latest_hb.get("timestamp", time.time())
else:
# Source 2 : scan disque (utile après restart serveur, avant que
# _last_heartbeat ne se repeuple — ou si l'agent V1 ne polle pas)
try:
import glob as _glob
pattern = "/home/dom/ai/rpa_vision_v3/data/training/live_sessions/*/bg_*/shots/heartbeat_*.png"
all_files = _glob.glob(pattern)
files = [
f for f in all_files
if "_blurred" not in f and os.path.isfile(f)
]
logger.info(
"[RESOLVE_TARGET] Scan disque : %d match glob, %d non-blurred existants",
len(all_files), len(files),
)
if files:
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
candidate_path = files[0]
candidate_age_s = time.time() - os.path.getmtime(candidate_path)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Scan disque heartbeat échoué : %s", e)
if candidate_path:
try:
img = Image.open(candidate_path)
effective_w, effective_h = img.size
logger.info(
"[RESOLVE_TARGET] Heartbeat fallback OK : %s (%dx%d, age=%.1fs)",
candidate_path, effective_w, effective_h, candidate_age_s or -1,
)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Ouverture heartbeat échouée : %s", e)
else:
logger.warning("[RESOLVE_TARGET] Aucun heartbeat disponible pour fallback")
# Sauver temporairement pour les analyseurs (ils attendent un chemin fichier) # Sauver temporairement pour les analyseurs (ils attendent un chemin fichier)
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
img.save(tmp, format="JPEG", quality=90) img.save(tmp, format="JPEG", quality=90)
@@ -4415,8 +4478,8 @@ async def resolve_target(request: ResolveTargetRequest):
_resolve_target_sync, _resolve_target_sync,
tmp_path, tmp_path,
request.target_spec, request.target_spec,
request.screen_width, effective_w,
request.screen_height, effective_h,
request.fallback_x_pct, request.fallback_x_pct,
request.fallback_y_pct, request.fallback_y_pct,
request.strict_mode, request.strict_mode,
@@ -4447,7 +4510,8 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}") logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}")
return _fallback_response(request, "analysis_error", str(e)) return _fallback_response(request, "analysis_error", str(e))
finally: finally:
import os # `os` est déjà importé en haut du fichier — pas de re-import local
# (sinon UnboundLocalError plus haut dans la fonction).
try: try:
os.unlink(tmp_path) os.unlink(tmp_path)
except OSError: except OSError: