fix(stream+vwb): chaîne replay robuste — auth, anchor type_text, lock async, drift, prompt LLM
Six modifications structurelles côté serveur, non destructives, aboutissant à un
pipeline replay bien plus stable pour la démo GHT Sud 95 (Urgences UHCD).
1. visual_workflow_builder/backend/app.py
load_dotenv() chargeait .env (cwd) au lieu de .env.local racine projet.
Conséquence : RPA_API_TOKEN absent après chaque restart manuel du backend
et tous les proxies VWB→streaming échouaient en 401 « Token API invalide ».
Charge maintenant explicitement .env.local du project root.
2. visual_workflow_builder/backend/api_v3/learned_workflows.py
Quatre appels proxy /api/v1/traces/stream/* ne portaient pas le Bearer.
Helper _stream_headers() factorisé et appliqué (workflows list/detail,
workflow detail, reload-workflows).
3. visual_workflow_builder/backend/api_v3/dag_execute.py
_ANCHOR_CLICK_TYPES excluait type_text/type_secret : pas de pre-click de
focus avant la frappe → texte tapé sans focus → textareas vides au replay.
Helper _inject_anchor_targeting() factorisé (centre bbox + visual_mode +
target_spec) appliqué aux click_anchor* ET aux type_text/type_secret dès
qu'un anchor_id est présent. Workflows historiques sans anchor sur
type_text → comportement inchangé.
4. agent_v0/server_v1/api_stream.py — endpoint /replay/next
_replay_lock (threading.Lock global) tenu pendant les actions serveur
lentes (extract_text OCR ~5s, t2a_decision LLM ~8-13s). Comme le handler
est async def, l'event loop FastAPI était bloqué : les polls clients
timeout à 5s, leurs actions étaient popped serveur sans destinataire,
perdues silencieusement. Mesure : 8 actions/25 perdues sur replay Urgence.
acquire(timeout=4.5) puis run_in_executor pour libérer l'event loop
pendant l'attente du lock ET pendant les handlers serveur synchrones.
Pendant un t2a_decision en cours, les polls concurrents reçoivent
immédiatement {action: null, server_busy: true} → l'agent ne timeout
plus, aucune action n'est popped sans destinataire.
5. agent_v0/server_v1/resolve_engine.py — _validate_resolution_quality
Drift > 0.20 par rapport aux coords enregistrées → fallback aux coords
enregistrées même quand le template matching trouve l'image avec un
score quasi parfait. Or un score >= 0.95 signifie que l'image EST
visuellement à l'écran à l'endroit indiqué, le drift reflète juste
un changement de layout (scroll, F11, redimensionnement), pas une
erreur. Exception ajoutée : score >= 0.95 sur template_matching →
ignore drift check, utilise position visuelle.
6. core/llm/t2a_decision.py — prompt T2A/PMSI
Ancien prompt autorisait « Critère non validé » en fallback creux.
Nouveau prompt impose au moins une CITATION LITTÉRALE entre « ... »
du DPI dans chaque preuve_critereN, qu'elle soutienne ou infirme le
critère. Si non validé : factualisation explicite (« Aucune ... »,
« Sortie à H+2 ») citée du dossier. Sortie = preuves cliniques
traçables et professionnelles, pas du remplissage.
État DB : aucun changement net (bbox patchés puis revertés depuis backup
visual_anchors_backup_20260501 ; by_text re-aligné sur 25003284). Le
re-enregistrement du workflow Urgence en conditions bureau standard
(Chrome normal, taille fenêtre standard) est l'étape suivante côté Dom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2762,8 +2762,29 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
|
||||
Si la session de l'agent n'a pas d'actions en attente, cherche dans les
|
||||
autres queues de la MÊME machine (pas cross-machine).
|
||||
|
||||
Acquire timeout : si une action serveur lente (extract_text OCR,
|
||||
t2a_decision LLM) tient le lock, on retourne immédiatement
|
||||
{action: None, server_busy: True} avant que le client ne timeout à 5s.
|
||||
Sans cela, des actions seraient popped serveur puis envoyées sur des
|
||||
sockets clients déjà fermées par timeout — perdues silencieusement.
|
||||
|
||||
L'acquire et les actions serveur lentes sont exécutés via
|
||||
run_in_executor : sinon l'appel synchrone bloque l'event loop FastAPI
|
||||
(single-threaded) et même les polls qui devraient recevoir server_busy
|
||||
sont bloqués jusqu'à libération — ce qui annule l'effet du timeout.
|
||||
"""
|
||||
with _replay_lock:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 4.5)
|
||||
if not acquired:
|
||||
return {
|
||||
"action": None,
|
||||
"session_id": session_id,
|
||||
"machine_id": machine_id,
|
||||
"server_busy": True,
|
||||
}
|
||||
try:
|
||||
# Verifier si le replay est en pause supervisee (target_not_found).
|
||||
# Dans ce cas, NE PAS envoyer d'action — attendre l'intervention utilisateur.
|
||||
for state in _replay_states.values():
|
||||
@@ -2828,6 +2849,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
break
|
||||
if target_state:
|
||||
queue = target_queue
|
||||
owning_replay = target_state
|
||||
_replay_queues[session_id] = target_queue
|
||||
del _replay_queues[target_sid]
|
||||
target_state["session_id"] = session_id
|
||||
@@ -2844,6 +2866,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
other_queue = _replay_queues.get(other_sid, [])
|
||||
if other_queue:
|
||||
queue = other_queue
|
||||
owning_replay = state
|
||||
_replay_queues[session_id] = other_queue
|
||||
del _replay_queues[other_sid]
|
||||
state["session_id"] = session_id
|
||||
@@ -2869,53 +2892,65 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
|
||||
type_ = action.get("type")
|
||||
|
||||
# pause_for_human : bascule en paused_need_help, return action=None
|
||||
if type_ == "pause_for_human" and owning_replay is not None:
|
||||
params = action.get("parameters") or {}
|
||||
message = params.get("message") or "Validation requise"
|
||||
# pause_for_human : no-op en mode autonome — on saute et on continue
|
||||
if type_ == "pause_for_human":
|
||||
logger.info(
|
||||
"pause_for_human ignorée (mode autonome) — replay %s continue",
|
||||
owning_replay["replay_id"] if owning_replay else "?"
|
||||
)
|
||||
queue.pop(0)
|
||||
_replay_queues[session_id] = queue
|
||||
owning_replay["status"] = "paused_need_help"
|
||||
owning_replay["pause_message"] = message
|
||||
owning_replay["failed_action"] = {
|
||||
"action_id": action.get("action_id", ""),
|
||||
"type": "pause_for_human",
|
||||
"reason": "user_request",
|
||||
}
|
||||
logger.info(
|
||||
f"Replay {owning_replay['replay_id']} pause supervisée demandée "
|
||||
f"par le workflow : {message[:80]}"
|
||||
)
|
||||
return {
|
||||
"action": None,
|
||||
"session_id": session_id,
|
||||
"machine_id": machine_id,
|
||||
"replay_paused": True,
|
||||
"pause_message": message,
|
||||
"replay_id": owning_replay["replay_id"],
|
||||
}
|
||||
continue
|
||||
|
||||
# Actions serveur : exécuter, pop, continuer
|
||||
# Actions serveur : exécuter HORS event loop pour ne pas bloquer
|
||||
# les autres polls (extract_text OCR ~5s, t2a_decision LLM ~8-13s).
|
||||
# Le lock reste tenu (queue cohérente) mais l'event loop est libre,
|
||||
# donc les polls concurrents peuvent recevoir {server_busy: True}.
|
||||
if type_ in _SERVER_SIDE_ACTION_TYPES and owning_replay is not None:
|
||||
try:
|
||||
if type_ == "extract_text":
|
||||
_handle_extract_text_action(
|
||||
action, owning_replay, session_id, _last_heartbeat
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_text_action,
|
||||
action, owning_replay, session_id, _last_heartbeat,
|
||||
)
|
||||
elif type_ == "t2a_decision":
|
||||
_handle_t2a_decision_action(action, owning_replay)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_handle_t2a_decision_action,
|
||||
action, owning_replay,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Action serveur {type_} a levé : {e}")
|
||||
queue.pop(0)
|
||||
_replay_queues[session_id] = queue
|
||||
continue # action suivante
|
||||
|
||||
# Clic conditionnel : si l'action a un paramètre "condition", évaluer la variable
|
||||
# Format : "dec.critere1_valide" → runtime_vars["dec"]["critere1_valide"]
|
||||
condition_key = (action.get("parameters") or {}).get("condition")
|
||||
if condition_key and owning_replay is not None:
|
||||
runtime_vars = owning_replay.get("variables") or {}
|
||||
parts = condition_key.split(".", 1)
|
||||
if len(parts) == 2:
|
||||
val = (runtime_vars.get(parts[0]) or {}).get(parts[1])
|
||||
else:
|
||||
val = runtime_vars.get(parts[0])
|
||||
if not val:
|
||||
logger.info("Clic conditionnel ignoré (%s=%s) — action %s",
|
||||
condition_key, val, action.get("action_id", "?"))
|
||||
queue.pop(0)
|
||||
_replay_queues[session_id] = queue
|
||||
continue
|
||||
|
||||
# Action visuelle : sortir de la boucle pour la transmettre à l'Agent V1
|
||||
break
|
||||
|
||||
# Si la queue s'est vidée après les exécutions serveur, rien à transmettre
|
||||
if not queue or action is None:
|
||||
return {"action": None, "session_id": session_id, "machine_id": machine_id}
|
||||
finally:
|
||||
_replay_lock.release()
|
||||
|
||||
# ---- Pre-check écran (optionnel, non bloquant) ----
|
||||
# Ne s'applique qu'aux actions qui ont un from_node (actions de workflow,
|
||||
@@ -3943,7 +3978,9 @@ async def resume_replay(replay_id: str):
|
||||
state["pause_message"] = None
|
||||
|
||||
# Reinjecter l'action echouee en tete de queue (sera re-tentee)
|
||||
if failed_action and failed_action.get("action_id"):
|
||||
# pause_for_human est une pause intentionnelle, pas une erreur — ne pas réinjecter
|
||||
if (failed_action and failed_action.get("action_id")
|
||||
and failed_action.get("reason") != "user_request"):
|
||||
# Reconstruire l'action a partir du retry_pending ou de l'original
|
||||
original_action_id = failed_action["action_id"]
|
||||
# Chercher l'action originale dans les retry_pending
|
||||
@@ -3984,6 +4021,26 @@ async def resume_replay(replay_id: str):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/replay/{replay_id}/cancel")
|
||||
async def cancel_replay(replay_id: str):
|
||||
"""Annuler un replay (quel que soit son statut) et vider sa queue."""
|
||||
with _replay_lock:
|
||||
state = _replay_states.get(replay_id)
|
||||
if not state:
|
||||
raise HTTPException(status_code=404, detail=f"Replay '{replay_id}' non trouvé")
|
||||
session_id = state["session_id"]
|
||||
state["status"] = "cancelled"
|
||||
state["failed_action"] = None
|
||||
state["pause_message"] = None
|
||||
_replay_queues[session_id] = []
|
||||
keys_to_del = [k for k, v in _retry_pending.items() if v.get("replay_id") == replay_id]
|
||||
for k in keys_to_del:
|
||||
_retry_pending.pop(k, None)
|
||||
|
||||
logger.info("Replay %s annulé manuellement", replay_id)
|
||||
return {"status": "cancelled", "replay_id": replay_id, "session_id": session_id}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Visual Replay — Résolution visuelle des cibles (module resolve_engine)
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user