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