feat(workflow): variables runtime + extract_text serveur + t2a_decision LLM

Pipeline streaming étendu pour supporter des actions exécutées entièrement
côté serveur (jamais transmises à l'Agent V1) qui produisent des variables
réutilisables dans les steps suivants via templating {{var}} ou {{var.field}}.

== Variables d'exécution ==
- replay_state["variables"] : Dict[str, Any] initialisé vide à la création
- _resolve_runtime_vars() : résout {{var}} et {{var.field}} récursivement
  dans str/dict/list. Variables absentes laissées intactes.
- /replay/next applique la résolution sur l'action AVANT toute interception
  ou envoi à l'Agent V1.

== Boucle d'exécution serveur ==
- _SERVER_SIDE_ACTION_TYPES = {"extract_text", "t2a_decision"}
- /replay/next pop+execute en boucle ces actions jusqu'à trouver une action
  visuelle (à transmettre Agent V1) ou un pause_for_human (qui bloque).
- Latence acceptable : t2a_decision = 5-10s côté serveur, l'Agent V1 attend
  la réponse HTTP.

== Action extract_text ==
- Handler côté serveur réutilisant le dernier heartbeat (max 5s d'âge)
- core/llm/ocr_extractor.py : EasyOCR fr+en singleton + extract_text_from_image
- Stockage dans replay_state["variables"][output_var]
- Robuste : pas de heartbeat → variable = "" + log warning, pipeline continue

== Action t2a_decision ==
- core/llm/t2a_decision.py : refactor de demo_app.py query_model en module
  importable. Prompt expert DIM T2A/PMSI, qwen2.5:7b par défaut (100% bench).
- Handler côté serveur appelle analyze_dpi(input_template_resolved)
- Stockage du JSON décision dans replay_state["variables"][output_var]
- Erreurs (Ollama down, parse) → variable = INDETERMINE + _error, pipeline continue

== VWB UI ==
- types.ts : nouveau type 't2a_decision' (icône 🧠 catégorie logic)
- extract_text refondu : needsAnchor=false, paramètre output_var (au lieu de
  variable_name legacy — bridge accepte les deux pour compat)
- Bridge VWB→core : passthrough des deux types + paramètres préservés

== Tests ==
- tests/integration/test_t2a_extract.py : 25 tests verts
  - templating runtime (8 tests)
  - handler extract_text (3 tests, OCR mocké)
  - handler t2a_decision (3 tests, analyze_dpi mocké)
  - edge → action normalisée (2 tests)
  - bridge VWB → core (5 tests)
  - workflow chain extract→t2a→pause→clic (1 test)

Total branche : 82/82 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-29 22:47:31 +02:00
parent a67d896104
commit 964856ab30
8 changed files with 779 additions and 34 deletions

View File

@@ -219,6 +219,10 @@ from .replay_engine import (
_is_learned_workflow,
_edge_to_normalized_actions,
_substitute_variables,
_resolve_runtime_vars,
_SERVER_SIDE_ACTION_TYPES,
_handle_extract_text_action,
_handle_t2a_decision_action,
_expand_compound_steps,
_pre_check_screen_state as _pre_check_screen_state_impl,
_detect_popup_hint as _detect_popup_hint_impl,
@@ -2850,37 +2854,68 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
if not queue:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# Peek à la prochaine action SANS la retirer (pour le pre-check)
action = queue[0]
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision)
# exécutées entièrement côté serveur jusqu'à trouver une action visuelle
# à transmettre à l'Agent V1 ou un pause_for_human qui bloque le replay.
action = None
while queue:
action = queue[0]
# ── pause_for_human : interception avant exécution ──
# Cette action n'est jamais transmise à l'Agent V1. Elle bascule
# le replay en paused_need_help avec le message custom, ce qui
# déclenche la bulle interactive ChatWindow (J3.5).
if action.get("type") == "pause_for_human" and owning_replay is not None:
params = action.get("parameters") or {}
message = params.get("message") or "Validation requise"
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"],
}
# Résoudre les variables runtime ({{var}} et {{var.field}})
if owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
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"
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"],
}
# Actions serveur : exécuter, pop, continuer
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
)
elif type_ == "t2a_decision":
_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
# 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}
# ---- Pre-check écran (optionnel, non bloquant) ----
# Ne s'applique qu'aux actions qui ont un from_node (actions de workflow,