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:
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
131
tests/integration/test_pause_for_human.py
Normal file
131
tests/integration/test_pause_for_human.py
Normal 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 ?"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user