feat(workflow): action 'pause_for_human' — pause supervisée scriptée dans VWB

Nouvelle action native VWB qui force le replay à basculer en paused_need_help
avec un message custom. Quand Léa atteint cette étape, elle ne tente pas
d'exécuter — elle pose immédiatement le state, ce qui déclenche la bulle
interactive ChatWindow (J3.5) avec boutons Continuer / Annuler.

Asset démo majeur GHT Sud 95 : permet de scénariser le moment "Léa doute"
au bon endroit dans le workflow, sans dépendre d'un échec aléatoire.

Chaîne complète :
- VWB UI (types.ts) : nouvelle entrée ACTIONS catégorie 'logic', icône ⏸,
  paramètre 'message' éditable (textarea).
- Bridge VWB → core (learned_workflow_bridge.py) : passthrough du type +
  préservation du message dans parameters.
- Pipeline replay (replay_engine.py) : type ajouté à _ALLOWED_ACTION_TYPES,
  conversion edge → action normalisée préserve le message.
- Streaming server (api_stream.py /replay/next) : interception avant envoi
  à l'Agent V1 → bascule state en paused_need_help avec pause_message,
  retourne {action: None, replay_paused: True}.
- L'action n'est jamais transmise à l'Agent V1 — pure logique serveur.

10 nouveaux tests pytest. Total branche : 57/57 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-29 16:37:46 +02:00
parent 41c1250c99
commit 0e6e61f2b1
5 changed files with 177 additions and 1 deletions

View File

@@ -2853,6 +2853,35 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
# Peek à la prochaine action SANS la retirer (pour le pre-check)
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"],
}
# ---- Pre-check écran (optionnel, non bloquant) ----
# Ne s'applique qu'aux actions qui ont un from_node (actions de workflow,
# pas les wait/retry auto-injectés ni les actions Copilot/Agent Libre)

View File

@@ -32,7 +32,8 @@ _ALLOWED_ACTION_TYPES = {
"click", "type", "key_combo", "scroll", "wait",
"file_open", "file_save", "file_close", "file_new", "file_dialog",
"double_click", "right_click", "drag",
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
}
_MAX_ACTION_TEXT_LENGTH = 10000
_MAX_KEYS_PER_COMBO = 10
@@ -852,6 +853,13 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
keys = [action_params["key"]]
normalized["keys"] = keys
elif action_type == "pause_for_human":
normalized["type"] = "pause_for_human"
normalized["parameters"] = {
"message": action_params.get("message", "Validation requise"),
}
return [normalized] # pas de target/coords pour cette action logique
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []

View File

@@ -0,0 +1,131 @@
"""Tests de l'action pause_for_human (C.5).
Vérifie la chaîne :
- Validation côté replay_engine accepte le nouveau type
- Conversion edge → action normalisée préserve le message
- Bridge VWB → core mappe correctement
- Le bridge VWB construit bien un edge avec action.type='pause_for_human'
"""
from agent_v0.server_v1.replay_engine import (
_ALLOWED_ACTION_TYPES,
_validate_replay_action,
_edge_to_normalized_actions,
)
from visual_workflow_builder.backend.services.learned_workflow_bridge import (
VWB_ACTION_TO_CORE,
convert_vwb_to_core_workflow,
_vwb_params_to_core,
)
# ----------------------------------------------------------------------
# Validation pipeline (replay_engine)
# ----------------------------------------------------------------------
def test_pause_for_human_in_allowed_types():
assert "pause_for_human" in _ALLOWED_ACTION_TYPES
def test_validate_pause_for_human_action_valid():
action = {"type": "pause_for_human", "parameters": {"message": "Valider UHCD ?"}}
assert _validate_replay_action(action) is None
def test_validate_pause_for_human_no_params_still_valid():
"""Le validateur ne doit pas exiger 'message' (fallback côté handler)."""
action = {"type": "pause_for_human"}
assert _validate_replay_action(action) is None
# ----------------------------------------------------------------------
# Conversion edge → action normalisée
# ----------------------------------------------------------------------
class _FakeAction:
def __init__(self, type_, parameters=None):
self.type = type_
self.target = None
self.parameters = parameters or {}
class _FakeEdge:
def __init__(self, action, edge_id="e1", from_node="n1", to_node="n2"):
self.edge_id = edge_id
self.from_node = from_node
self.to_node = to_node
self.action = action
def test_edge_to_action_pause_for_human_preserves_message():
edge = _FakeEdge(_FakeAction(
"pause_for_human",
parameters={"message": "Tu valides UHCD ?"},
))
actions = _edge_to_normalized_actions(edge, params={})
assert len(actions) == 1
a = actions[0]
assert a["type"] == "pause_for_human"
assert a["parameters"]["message"] == "Tu valides UHCD ?"
assert "x_pct" not in a # action logique, pas de coords
assert "y_pct" not in a
def test_edge_to_action_pause_for_human_default_message():
edge = _FakeEdge(_FakeAction("pause_for_human", parameters={}))
actions = _edge_to_normalized_actions(edge, params={})
assert actions[0]["parameters"]["message"] == "Validation requise"
def test_edge_to_action_pause_for_human_carries_edge_metadata():
edge = _FakeEdge(
_FakeAction("pause_for_human", parameters={"message": "x"}),
edge_id="edge_42", from_node="n_src", to_node="n_dst",
)
actions = _edge_to_normalized_actions(edge, params={})
a = actions[0]
assert a["edge_id"] == "edge_42"
assert a["from_node"] == "n_src"
assert a["to_node"] == "n_dst"
assert "action_id" in a
# ----------------------------------------------------------------------
# Bridge VWB → core
# ----------------------------------------------------------------------
def test_vwb_action_to_core_passthrough():
assert VWB_ACTION_TO_CORE["pause_for_human"] == "pause_for_human"
def test_vwb_params_to_core_preserves_message():
core_params = _vwb_params_to_core("pause_for_human", {"message": "Coucou"})
assert core_params == {"message": "Coucou"}
def test_vwb_params_to_core_default_message():
core_params = _vwb_params_to_core("pause_for_human", {})
assert core_params["message"] == "Validation requise"
def test_export_vwb_workflow_with_pause_step():
"""Un workflow VWB contenant une step pause_for_human doit produire un edge
avec action.type='pause_for_human' et message dans parameters."""
workflow_data = {"id": "wf_demo", "name": "Demo Urgences", "description": ""}
steps_data = [
{"id": "s1", "action_type": "click_anchor", "parameters": {"target_text": "25003284"}, "label": "Clic IPP"},
{"id": "s2", "action_type": "pause_for_human", "parameters": {"message": "Valider UHCD ?"}, "label": "Pause"},
{"id": "s3", "action_type": "click_anchor", "parameters": {"target_text": "Enregistrer"}, "label": "Clic Enregistrer"},
]
core = convert_vwb_to_core_workflow(workflow_data, steps_data)
assert core["learning_state"] == "COACHING"
assert len(core["nodes"]) == 3
assert len(core["edges"]) == 2
# L'edge sortant du node de pause doit avoir le bon type + message
pause_edges = [
e for e in core["edges"]
if e["action"]["type"] == "pause_for_human"
]
assert len(pause_edges) == 1
assert pause_edges[0]["action"]["parameters"]["message"] == "Valider UHCD ?"

View File

@@ -58,6 +58,7 @@ VWB_ACTION_TO_CORE = {
"visual_condition": "evaluate_condition",
"screenshot_evidence": "screenshot",
"extract_text": "extract_data",
"pause_for_human": "pause_for_human", # passthrough — intercepté par api_stream /replay/next
}
@@ -660,6 +661,9 @@ def _vwb_params_to_core(action_type: str, params: Dict[str, Any]) -> Dict[str, A
elif action_type == "wait_for_anchor":
core_params["duration_ms"] = params.get("duration_ms", 2000)
elif action_type == "pause_for_human":
core_params["message"] = params.get("message", "Validation requise")
return core_params

View File

@@ -43,6 +43,7 @@ export type ActionType =
| 'screenshot_evidence'
| 'visual_condition'
| 'loop_visual'
| 'pause_for_human'
| 'download_to_folder'
| 'ai_analyze_text'
| 'ai_ocr'
@@ -129,6 +130,9 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', description: 'Répète les étapes connectées tant que l\'ancre est visible.', category: 'logic', needsAnchor: true, hidden: true, params: [
{ name: 'max_iterations', type: 'number', description: 'Nombre maximum d\'itérations' }
] },
{ type: 'pause_for_human', label: 'Pause supervisée', icon: '⏸', description: 'Léa s\'arrête et demande validation humaine via une bulle interactive (boutons Continuer / Annuler).', category: 'logic', needsAnchor: false, params: [
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' }
] },
// === INTELLIGENCE ARTIFICIELLE ===
{ type: 'ai_ocr', label: 'OCR Intelligent', icon: '📝', description: 'Reconnaissance de texte par IA sur la zone de l\'ancre.', category: 'ai', needsAnchor: true, params: [