feat: runtime V4 — endpoints /workflow/compile et /replay/plan
Pipeline V4 complet disponible en API :
RawTrace → /workflow/compile → WorkflowIR + ExecutionPlan → /replay/plan → Runtime
- execution_plan_runner.py : adaptateur ExecutionNode → action executor
- Substitution variables {var} dans target/text
- Fusion stratégies primary + fallbacks (OCR, template, VLM)
- Clicks: coordonnées neutralisées, resolve_engine trouve au runtime
- 35 nouveaux tests (conversion, substitution, injection queue, pipeline E2E)
- Ancien chemin build_replay_from_raw_events() préservé (coexistence)
208 tests passent, 0 régression.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,10 @@ from .replay_learner import ReplayLearner
|
||||
from .audit_trail import AuditTrail, AuditEntry
|
||||
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
|
||||
from .worker_stream import StreamWorker
|
||||
from .execution_plan_runner import (
|
||||
execution_plan_to_actions,
|
||||
inject_plan_into_queue,
|
||||
)
|
||||
|
||||
# Instance globale du vérificateur de replay (comparaison screenshots avant/après)
|
||||
_replay_verifier = ReplayVerifier()
|
||||
@@ -438,6 +442,34 @@ class SingleActionRequest(BaseModel):
|
||||
machine_id: Optional[str] = None # Machine cible (multi-machine)
|
||||
|
||||
|
||||
class PlanReplayRequest(BaseModel):
|
||||
"""Requête de lancement de replay depuis un ExecutionPlan (pipeline V4).
|
||||
|
||||
Deux modes supportés :
|
||||
1. Référence par ID : fournir `plan_id` → le serveur charge le plan
|
||||
depuis `data/plans/{plan_id}.json`.
|
||||
2. Plan inline : fournir `plan` (dict JSON) → utilisé directement.
|
||||
|
||||
Les `variables` écrasent celles du plan.
|
||||
"""
|
||||
plan_id: Optional[str] = None
|
||||
plan: Optional[Dict[str, Any]] = None
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
session_id: str = ""
|
||||
machine_id: Optional[str] = None
|
||||
|
||||
|
||||
class CompileWorkflowRequest(BaseModel):
|
||||
"""Requête de compilation d'une session en WorkflowIR + ExecutionPlan."""
|
||||
session_id: str
|
||||
machine_id: str = "default"
|
||||
domain: str = "generic"
|
||||
name: str = ""
|
||||
target_machine: str = ""
|
||||
target_resolution: str = "1280x800"
|
||||
params: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class ReplayResultReport(BaseModel):
|
||||
"""Rapport de résultat d'exécution d'une action par l'Agent V1."""
|
||||
session_id: str
|
||||
@@ -1906,6 +1938,369 @@ async def enqueue_single_action(request: SingleActionRequest):
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pipeline V4 — ExecutionPlan → Runtime (nouveau chemin)
|
||||
# =========================================================================
|
||||
# RawTrace → IRBuilder → WorkflowIR → ExecutionCompiler → ExecutionPlan → Runtime
|
||||
#
|
||||
# Ces deux endpoints sont optionnels et coexistent avec le chemin legacy
|
||||
# (build_replay_from_raw_events() dans stream_processor.py). Ils permettent
|
||||
# de lancer un replay depuis un plan pré-compilé, déterministe et borné.
|
||||
# =========================================================================
|
||||
|
||||
# Répertoires par défaut pour la persistance du pipeline V4
|
||||
WORKFLOWS_IR_DIR = ROOT_DIR / "data" / "workflows_ir"
|
||||
EXECUTION_PLANS_DIR = ROOT_DIR / "data" / "plans"
|
||||
|
||||
|
||||
def _load_execution_plan(plan_id: str):
|
||||
"""Charger un ExecutionPlan depuis le disque (data/plans/{id}.json)."""
|
||||
from core.workflow.execution_plan import ExecutionPlan
|
||||
|
||||
# Chemin direct
|
||||
candidate = EXECUTION_PLANS_DIR / f"{plan_id}.json"
|
||||
if candidate.exists():
|
||||
return ExecutionPlan.load(str(candidate))
|
||||
|
||||
# Fallback : recherche par prefix (plan_id sans _vN)
|
||||
if EXECUTION_PLANS_DIR.exists():
|
||||
for p in EXECUTION_PLANS_DIR.glob(f"{plan_id}*.json"):
|
||||
return ExecutionPlan.load(str(p))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/replay/plan")
|
||||
async def launch_replay_from_plan(request: PlanReplayRequest):
|
||||
"""Lancer un replay depuis un ExecutionPlan (pipeline V4).
|
||||
|
||||
Pipeline :
|
||||
1. Charger le plan (depuis plan_id sur disque ou depuis le body inline)
|
||||
2. Convertir chaque ExecutionNode en action replay via
|
||||
execution_plan_runner.execution_plan_to_actions()
|
||||
3. Appliquer les variables (body > plan.variables)
|
||||
4. Valider chaque action (sécurité HIGH)
|
||||
5. Injecter dans la queue de replay de la session Agent V1 cible
|
||||
|
||||
Pas de dépendance au VLM au runtime pour les cas normaux — les stratégies
|
||||
de résolution sont déjà pré-compilées dans le plan.
|
||||
"""
|
||||
from core.workflow.execution_plan import ExecutionPlan
|
||||
|
||||
# ── 1. Charger / parser le plan ──
|
||||
plan = None
|
||||
if request.plan_id:
|
||||
plan = _load_execution_plan(request.plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"ExecutionPlan '{request.plan_id}' introuvable dans "
|
||||
f"{EXECUTION_PLANS_DIR}/",
|
||||
)
|
||||
elif request.plan:
|
||||
try:
|
||||
plan = ExecutionPlan.from_dict(request.plan)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Impossible de parser le plan inline : {e}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Fournir 'plan_id' (référence) ou 'plan' (inline).",
|
||||
)
|
||||
|
||||
if not plan.nodes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ExecutionPlan '{plan.plan_id}' : aucun nœud à exécuter.",
|
||||
)
|
||||
|
||||
# ── 2. Convertir les nœuds en actions replay ──
|
||||
try:
|
||||
actions = execution_plan_to_actions(
|
||||
plan=plan,
|
||||
variables=request.variables,
|
||||
id_prefix="act_plan",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Erreur conversion ExecutionPlan → actions")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur de conversion du plan : {e}",
|
||||
)
|
||||
|
||||
if not actions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ExecutionPlan '{plan.plan_id}' : aucune action exploitable "
|
||||
f"après conversion ({plan.total_nodes} nœuds).",
|
||||
)
|
||||
|
||||
# Limite de sécurité
|
||||
if len(actions) > MAX_ACTIONS_PER_REPLAY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Trop d'actions ({len(actions)} > {MAX_ACTIONS_PER_REPLAY}).",
|
||||
)
|
||||
|
||||
# ── 3. Validation de chaque action (sécurité HIGH) ──
|
||||
validated: List[Dict[str, Any]] = []
|
||||
for i, action in enumerate(actions):
|
||||
error = _validate_replay_action(action)
|
||||
if error:
|
||||
logger.warning(
|
||||
"replay/plan : action #%d invalide (%s), suppression", i, error,
|
||||
)
|
||||
continue
|
||||
validated.append(action)
|
||||
|
||||
if not validated:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ExecutionPlan '{plan.plan_id}' : toutes les actions "
|
||||
f"ont été rejetées par la validation.",
|
||||
)
|
||||
|
||||
# ── 4. Trouver la session Agent V1 cible ──
|
||||
target_session_id = request.session_id
|
||||
if not target_session_id or target_session_id.startswith("chat_"):
|
||||
active_session = _find_active_agent_session(machine_id=request.machine_id)
|
||||
if active_session:
|
||||
target_session_id = active_session
|
||||
else:
|
||||
machine_hint = (
|
||||
f" sur la machine '{request.machine_id}'" if request.machine_id else ""
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Aucune session Agent V1 active{machine_hint}. "
|
||||
"Lancez l'Agent V1 sur le PC cible.",
|
||||
)
|
||||
|
||||
# ── 5. Injecter dans la queue de replay ──
|
||||
replay_id = f"replay_plan_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
session_obj = processor.session_manager.get_session(target_session_id)
|
||||
resolved_machine_id = (
|
||||
request.machine_id
|
||||
or (session_obj.machine_id if session_obj else "default")
|
||||
)
|
||||
|
||||
with _replay_lock:
|
||||
_replay_queues[target_session_id] = list(validated)
|
||||
_replay_states[replay_id] = _create_replay_state(
|
||||
replay_id=replay_id,
|
||||
workflow_id=f"execution_plan:{plan.plan_id}",
|
||||
session_id=target_session_id,
|
||||
total_actions=len(validated),
|
||||
params=dict(plan.variables or {}),
|
||||
machine_id=resolved_machine_id,
|
||||
)
|
||||
if resolved_machine_id and resolved_machine_id != "default":
|
||||
_machine_replay_target[resolved_machine_id] = target_session_id
|
||||
|
||||
# Signaler au worker VLM qu'un replay est actif → se suspendre
|
||||
_set_replay_lock(replay_id)
|
||||
|
||||
logger.info(
|
||||
"Replay plan V4 démarré : %s | plan=%s (v%d) | session=%s | "
|
||||
"machine=%s | %d actions (total_nodes=%d, rejected=%d)",
|
||||
replay_id, plan.plan_id, plan.version, target_session_id,
|
||||
resolved_machine_id, len(validated), plan.total_nodes,
|
||||
len(actions) - len(validated),
|
||||
)
|
||||
|
||||
return {
|
||||
"replay_id": replay_id,
|
||||
"status": "running",
|
||||
"plan_id": plan.plan_id,
|
||||
"workflow_id": plan.workflow_id,
|
||||
"plan_version": plan.version,
|
||||
"session_id": target_session_id,
|
||||
"machine_id": resolved_machine_id,
|
||||
"total_actions": len(validated),
|
||||
"total_nodes": plan.total_nodes,
|
||||
"rejected_actions": len(actions) - len(validated),
|
||||
"stats": {
|
||||
"nodes_with_ocr": plan.nodes_with_ocr,
|
||||
"nodes_with_template": plan.nodes_with_template,
|
||||
"nodes_with_vlm": plan.nodes_with_vlm,
|
||||
"estimated_duration_s": plan.estimated_duration_s,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/workflow/compile")
|
||||
async def compile_workflow_endpoint(request: CompileWorkflowRequest):
|
||||
"""Compiler une session en WorkflowIR + ExecutionPlan (pipeline V4).
|
||||
|
||||
Pipeline :
|
||||
1. Charger les événements bruts de la session (live_events.jsonl)
|
||||
2. IRBuilder.build() → WorkflowIR (connaissance métier)
|
||||
3. WorkflowIR.save() → persistance dans data/workflows_ir/
|
||||
4. ExecutionCompiler.compile() → ExecutionPlan (plan déterministe)
|
||||
5. ExecutionPlan.save() → persistance dans data/plans/
|
||||
6. Retourner les IDs pour lancer ensuite /replay/plan
|
||||
|
||||
Cette endpoint NE LANCE PAS le replay — elle prépare le plan.
|
||||
L'appelant doit ensuite appeler /replay/plan avec plan_id.
|
||||
"""
|
||||
from core.workflow.execution_compiler import ExecutionCompiler
|
||||
from core.workflow.ir_builder import IRBuilder
|
||||
|
||||
session_id = request.session_id
|
||||
machine_id = request.machine_id or "default"
|
||||
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=400, detail="session_id requis")
|
||||
|
||||
# ── 1. Trouver le fichier live_events.jsonl de la session ──
|
||||
events_file = None
|
||||
if machine_id and machine_id != "default":
|
||||
candidate = LIVE_SESSIONS_DIR / machine_id / session_id / "live_events.jsonl"
|
||||
if candidate.exists():
|
||||
events_file = candidate
|
||||
|
||||
if not events_file and LIVE_SESSIONS_DIR.exists():
|
||||
for machine_dir in LIVE_SESSIONS_DIR.iterdir():
|
||||
if not machine_dir.is_dir():
|
||||
continue
|
||||
candidate = machine_dir / session_id / "live_events.jsonl"
|
||||
if candidate.exists():
|
||||
events_file = candidate
|
||||
if machine_id == "default":
|
||||
machine_id = machine_dir.name
|
||||
break
|
||||
|
||||
if not events_file:
|
||||
candidate = LIVE_SESSIONS_DIR / session_id / "live_events.jsonl"
|
||||
if candidate.exists():
|
||||
events_file = candidate
|
||||
|
||||
if not events_file:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Session '{session_id}' : live_events.jsonl introuvable.",
|
||||
)
|
||||
|
||||
# ── 2. Charger les événements ──
|
||||
raw_events: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for line in events_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
raw_events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur lecture events : {e}",
|
||||
)
|
||||
|
||||
if not raw_events:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Session '{session_id}' : aucun événement.",
|
||||
)
|
||||
|
||||
# ── 3. IRBuilder → WorkflowIR ──
|
||||
try:
|
||||
builder = IRBuilder()
|
||||
ir = builder.build(
|
||||
events=raw_events,
|
||||
session_id=session_id,
|
||||
session_dir=str(events_file.parent),
|
||||
domain=request.domain,
|
||||
name=request.name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Erreur IRBuilder.build()")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur de construction WorkflowIR : {e}",
|
||||
)
|
||||
|
||||
if not ir.steps:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Session '{session_id}' : aucune étape détectée "
|
||||
f"(pipeline IRBuilder a produit un workflow vide).",
|
||||
)
|
||||
|
||||
# ── 4. Sauvegarder le WorkflowIR ──
|
||||
try:
|
||||
WORKFLOWS_IR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ir_path = ir.save(str(WORKFLOWS_IR_DIR))
|
||||
except Exception as e:
|
||||
logger.exception("Erreur sauvegarde WorkflowIR")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur sauvegarde WorkflowIR : {e}",
|
||||
)
|
||||
|
||||
# ── 5. ExecutionCompiler → ExecutionPlan ──
|
||||
try:
|
||||
compiler = ExecutionCompiler()
|
||||
plan = compiler.compile(
|
||||
ir=ir,
|
||||
target_machine=request.target_machine,
|
||||
target_resolution=request.target_resolution,
|
||||
params=request.params,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Erreur ExecutionCompiler.compile()")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur de compilation du plan : {e}",
|
||||
)
|
||||
|
||||
# ── 6. Sauvegarder l'ExecutionPlan ──
|
||||
try:
|
||||
EXECUTION_PLANS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
plan_path = plan.save(str(EXECUTION_PLANS_DIR))
|
||||
except Exception as e:
|
||||
logger.exception("Erreur sauvegarde ExecutionPlan")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur sauvegarde ExecutionPlan : {e}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Compilation V4 : session=%s → workflow_ir=%s (v%d) → plan=%s "
|
||||
"(%d nœuds, OCR=%d, template=%d, VLM=%d)",
|
||||
session_id, ir.workflow_id, ir.version, plan.plan_id,
|
||||
plan.total_nodes, plan.nodes_with_ocr, plan.nodes_with_template,
|
||||
plan.nodes_with_vlm,
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"machine_id": machine_id,
|
||||
"workflow_id": ir.workflow_id,
|
||||
"workflow_version": ir.version,
|
||||
"workflow_ir_path": str(ir_path),
|
||||
"workflow_name": ir.name,
|
||||
"domain": ir.domain,
|
||||
"steps": len(ir.steps),
|
||||
"variables": len(ir.variables),
|
||||
"applications": ir.applications,
|
||||
"plan_id": plan.plan_id,
|
||||
"plan_path": str(plan_path),
|
||||
"total_nodes": plan.total_nodes,
|
||||
"stats": {
|
||||
"nodes_with_ocr": plan.nodes_with_ocr,
|
||||
"nodes_with_template": plan.nodes_with_template,
|
||||
"nodes_with_vlm": plan.nodes_with_vlm,
|
||||
"estimated_duration_s": plan.estimated_duration_s,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pre-check écran — Vérification pré-action par embedding CLIP
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user