feat: ExecutionCompiler — compile WorkflowIR en plan d'exécution borné
Pièce maîtresse de l'architecture V4 : - ExecutionPlan : nœuds avec stratégies de résolution pré-compilées - ExecutionCompiler : WorkflowIR → ExecutionPlan déterministe - Résolution : OCR (primaire, 100ms) > template > VLM (exception handler) - Chaque nœud : timeout, max_retries, recovery, condition de succès - Variables substituables, versionné, sérialisable JSON - 18 tests (compilation, stratégies, fallbacks, variables, roundtrip) "Le LLM compile. Le runtime exécute." Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
280
core/workflow/execution_compiler.py
Normal file
280
core/workflow/execution_compiler.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# core/workflow/execution_compiler.py
|
||||
"""
|
||||
ExecutionCompiler — Compile un WorkflowIR en ExecutionPlan.
|
||||
|
||||
Pièce maîtresse de l'architecture V4.
|
||||
"Le LLM prépare et compile. Le runtime exécute."
|
||||
|
||||
Le compilateur :
|
||||
1. Prend chaque étape du WorkflowIR
|
||||
2. Compile une stratégie de résolution pour chaque action (OCR > template > VLM)
|
||||
3. Définit les timeouts, retries, fallbacks et recovery
|
||||
4. Produit un ExecutionPlan déterministe et borné
|
||||
|
||||
L'objectif : zéro VLM au runtime pour les cas normaux.
|
||||
Le VLM est un exception handler, pas le chemin principal.
|
||||
|
||||
Le compilateur utilise :
|
||||
- Les données de l'enregistrement (crops, textes OCR) pour pré-compiler
|
||||
- L'historique d'apprentissage (ReplayLearner) pour choisir la meilleure stratégie
|
||||
- Le contexte métier (DomainContext) pour adapter les paramètres
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .workflow_ir import WorkflowIR, Step, Action
|
||||
from .execution_plan import (
|
||||
ExecutionPlan, ExecutionNode, ResolutionStrategy, SuccessCondition,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Temps estimé par type d'action (ms)
|
||||
_ACTION_TIME_ESTIMATES = {
|
||||
"click": 200, # OCR lookup + clic
|
||||
"type": 500, # Frappe char-by-char
|
||||
"key_combo": 100,
|
||||
"wait": 0, # Le duration_ms est dans l'action
|
||||
"scroll": 200,
|
||||
}
|
||||
|
||||
|
||||
class ExecutionCompiler:
|
||||
"""Compile un WorkflowIR en ExecutionPlan.
|
||||
|
||||
Usage :
|
||||
compiler = ExecutionCompiler()
|
||||
plan = compiler.compile(workflow_ir, target_machine="VM_Win11")
|
||||
plan.save("data/plans/")
|
||||
"""
|
||||
|
||||
def __init__(self, learning_dir: str = ""):
|
||||
self._learning_dir = learning_dir or "data/learning/replay_results"
|
||||
|
||||
def compile(
|
||||
self,
|
||||
ir: WorkflowIR,
|
||||
target_machine: str = "",
|
||||
target_resolution: str = "1280x800",
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
) -> ExecutionPlan:
|
||||
"""Compiler un WorkflowIR en ExecutionPlan.
|
||||
|
||||
Args:
|
||||
ir: Le WorkflowIR à compiler
|
||||
target_machine: Machine cible (pour adapter les stratégies)
|
||||
target_resolution: Résolution de la machine cible
|
||||
params: Variables à substituer
|
||||
"""
|
||||
t_start = time.time()
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=f"plan_{uuid.uuid4().hex[:8]}",
|
||||
workflow_id=ir.workflow_id,
|
||||
version=ir.version,
|
||||
created_at=time.time(),
|
||||
domain=ir.domain,
|
||||
target_machine=target_machine,
|
||||
target_resolution=target_resolution,
|
||||
variables=params or {v.name: v.default for v in ir.variables},
|
||||
)
|
||||
|
||||
# Consulter l'historique d'apprentissage
|
||||
learned_strategies = self._load_learned_strategies()
|
||||
|
||||
# Compiler chaque étape
|
||||
for step in ir.steps:
|
||||
nodes = self._compile_step(step, ir, learned_strategies)
|
||||
plan.nodes.extend(nodes)
|
||||
|
||||
# Statistiques de compilation
|
||||
plan.total_nodes = len(plan.nodes)
|
||||
plan.nodes_with_ocr = sum(
|
||||
1 for n in plan.nodes
|
||||
if n.strategy_primary and n.strategy_primary.method == "ocr"
|
||||
)
|
||||
plan.nodes_with_template = sum(
|
||||
1 for n in plan.nodes
|
||||
if n.strategy_primary and n.strategy_primary.method == "template"
|
||||
)
|
||||
plan.nodes_with_vlm = sum(
|
||||
1 for n in plan.nodes
|
||||
if n.strategy_primary and n.strategy_primary.method == "vlm"
|
||||
)
|
||||
plan.estimated_duration_s = sum(
|
||||
_ACTION_TIME_ESTIMATES.get(n.action_type, 200) + n.duration_ms
|
||||
for n in plan.nodes
|
||||
) / 1000.0
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
logger.info(
|
||||
f"Compilation: {plan.total_nodes} nœuds en {elapsed:.1f}s — "
|
||||
f"OCR={plan.nodes_with_ocr}, template={plan.nodes_with_template}, "
|
||||
f"VLM={plan.nodes_with_vlm} (exception handler)"
|
||||
)
|
||||
|
||||
return plan
|
||||
|
||||
def _compile_step(
|
||||
self,
|
||||
step: Step,
|
||||
ir: WorkflowIR,
|
||||
learned: Dict[str, str],
|
||||
) -> List[ExecutionNode]:
|
||||
"""Compiler une étape en nœuds d'exécution."""
|
||||
nodes = []
|
||||
|
||||
for i, action in enumerate(step.actions):
|
||||
node = self._compile_action(
|
||||
action=action,
|
||||
step=step,
|
||||
action_index=i,
|
||||
ir=ir,
|
||||
learned=learned,
|
||||
)
|
||||
nodes.append(node)
|
||||
|
||||
return nodes
|
||||
|
||||
def _compile_action(
|
||||
self,
|
||||
action: Action,
|
||||
step: Step,
|
||||
action_index: int,
|
||||
ir: WorkflowIR,
|
||||
learned: Dict[str, str],
|
||||
) -> ExecutionNode:
|
||||
"""Compiler une action en nœud d'exécution avec stratégie de résolution."""
|
||||
|
||||
node = ExecutionNode(
|
||||
node_id=f"n_{step.step_id}_{action_index}",
|
||||
action_type=action.type,
|
||||
intent=step.intent,
|
||||
step_id=step.step_id,
|
||||
is_optional=step.is_optional,
|
||||
)
|
||||
|
||||
if action.type == "click":
|
||||
# Compiler les stratégies de résolution pour ce clic
|
||||
node.strategy_primary, node.strategy_fallbacks = self._compile_click_resolution(
|
||||
action, step, learned,
|
||||
)
|
||||
node.timeout_ms = 10000
|
||||
node.max_retries = 2
|
||||
node.recovery_action = "escape"
|
||||
|
||||
# Condition de succès basée sur la postcondition
|
||||
if step.postcondition:
|
||||
node.success_condition = SuccessCondition(
|
||||
method="screen_changed",
|
||||
description=step.postcondition,
|
||||
)
|
||||
|
||||
elif action.type == "type":
|
||||
node.text = action.text
|
||||
node.variable_name = action.text.strip("{}") if action.variable else ""
|
||||
node.timeout_ms = 5000
|
||||
node.max_retries = 0 # Pas de retry sur la frappe
|
||||
node.recovery_action = "undo"
|
||||
|
||||
elif action.type == "key_combo":
|
||||
node.keys = action.keys
|
||||
node.timeout_ms = 3000
|
||||
node.max_retries = 0
|
||||
node.recovery_action = "undo"
|
||||
|
||||
elif action.type == "wait":
|
||||
node.duration_ms = action.duration_ms or 1000
|
||||
node.timeout_ms = action.duration_ms + 2000
|
||||
node.max_retries = 0
|
||||
node.recovery_action = "none"
|
||||
|
||||
elif action.type == "scroll":
|
||||
node.timeout_ms = 3000
|
||||
node.max_retries = 0
|
||||
node.recovery_action = "none"
|
||||
|
||||
return node
|
||||
|
||||
def _compile_click_resolution(
|
||||
self,
|
||||
action: Action,
|
||||
step: Step,
|
||||
learned: Dict[str, str],
|
||||
) -> tuple:
|
||||
"""Compiler les stratégies de résolution pour un clic.
|
||||
|
||||
Ordre de priorité :
|
||||
1. OCR exact (si texte connu) — 100ms, pixel-perfect
|
||||
2. Template matching (si crop disponible) — 10ms, même interface
|
||||
3. Position relative (si hint disponible) — instantané, fragile
|
||||
4. VLM (dernier recours) — 2-5s, exception handler
|
||||
|
||||
Le learning peut réordonner si une stratégie a mieux marché avant.
|
||||
"""
|
||||
primary = None
|
||||
fallbacks = []
|
||||
|
||||
target_text = action.anchor_hint or action.target
|
||||
learned_method = learned.get(target_text, "")
|
||||
|
||||
# Stratégie OCR — le texte visible est la meilleure ancre
|
||||
if target_text:
|
||||
ocr_strategy = ResolutionStrategy(
|
||||
method="ocr",
|
||||
target_text=target_text,
|
||||
threshold=0.8,
|
||||
)
|
||||
# Si le learning dit que l'OCR marche pour cette cible, c'est la primaire
|
||||
if not learned_method or learned_method in ("ocr", "som_text_match", "hybrid_text_direct"):
|
||||
primary = ocr_strategy
|
||||
else:
|
||||
fallbacks.append(ocr_strategy)
|
||||
|
||||
# Stratégie template — le crop visuel de l'enregistrement
|
||||
if action.anchor_hint:
|
||||
template_strategy = ResolutionStrategy(
|
||||
method="template",
|
||||
target_text=action.anchor_hint,
|
||||
threshold=0.85,
|
||||
)
|
||||
if learned_method in ("anchor_template", "template_matching"):
|
||||
primary = template_strategy
|
||||
else:
|
||||
fallbacks.append(template_strategy)
|
||||
|
||||
# Stratégie VLM — exception handler (dernier recours)
|
||||
vlm_description = action.target or step.intent
|
||||
vlm_strategy = ResolutionStrategy(
|
||||
method="vlm",
|
||||
vlm_description=vlm_description,
|
||||
threshold=0.6,
|
||||
)
|
||||
fallbacks.append(vlm_strategy)
|
||||
|
||||
# Si aucune primaire trouvée, utiliser le VLM
|
||||
if primary is None:
|
||||
if fallbacks:
|
||||
primary = fallbacks.pop(0)
|
||||
else:
|
||||
primary = vlm_strategy
|
||||
|
||||
return primary, fallbacks
|
||||
|
||||
def _load_learned_strategies(self) -> Dict[str, str]:
|
||||
"""Charger les stratégies apprises (ReplayLearner)."""
|
||||
try:
|
||||
from agent_v0.server_v1.replay_learner import ReplayLearner
|
||||
learner = ReplayLearner(learning_dir=self._learning_dir)
|
||||
# Construire un mapping target → best_method depuis l'historique
|
||||
strategies = {}
|
||||
for outcome in learner._recent:
|
||||
if outcome.success and outcome.resolution_method and outcome.target_description:
|
||||
strategies[outcome.target_description] = outcome.resolution_method
|
||||
return strategies
|
||||
except Exception:
|
||||
return {}
|
||||
251
core/workflow/execution_plan.py
Normal file
251
core/workflow/execution_plan.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# core/workflow/execution_plan.py
|
||||
"""
|
||||
ExecutionPlan — Plan d'exécution strict, borné et versionné.
|
||||
|
||||
C'est ce que le runtime exécute. Pas d'improvisation — tout est pré-compilé :
|
||||
- chaque nœud a une stratégie de résolution primaire + fallbacks
|
||||
- chaque nœud a un timeout, un retry policy, une condition de succès
|
||||
- le VLM n'intervient qu'en exception handler (pas en chemin principal)
|
||||
|
||||
Le runtime ne fait que : exécuter → observer → vérifier → suite ou fallback.
|
||||
|
||||
Cycle : WorkflowIR → ExecutionCompiler → ExecutionPlan → Runtime
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolutionStrategy:
|
||||
"""Stratégie de résolution visuelle pour un élément UI.
|
||||
|
||||
Pré-compilée — le runtime n'a pas besoin du VLM pour résoudre.
|
||||
"""
|
||||
method: str # "ocr", "template", "position", "vlm"
|
||||
target_text: str = "" # Texte à chercher (pour OCR)
|
||||
anchor_b64: str = "" # Crop de référence (pour template matching)
|
||||
zone: Dict[str, float] = field(default_factory=dict) # Zone de recherche {x_min, y_min, x_max, y_max}
|
||||
position_hint: str = "" # "en haut à droite", "dans la barre des tâches"
|
||||
vlm_description: str = "" # Description VLM (dernier recours)
|
||||
threshold: float = 0.8 # Seuil de confiance
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {"method": self.method}
|
||||
if self.target_text:
|
||||
d["target_text"] = self.target_text
|
||||
if self.anchor_b64:
|
||||
d["anchor_b64"] = self.anchor_b64[:50] + "..." # Tronqué pour la lisibilité
|
||||
if self.zone:
|
||||
d["zone"] = self.zone
|
||||
if self.position_hint:
|
||||
d["position_hint"] = self.position_hint
|
||||
if self.vlm_description:
|
||||
d["vlm_description"] = self.vlm_description
|
||||
d["threshold"] = self.threshold
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> "ResolutionStrategy":
|
||||
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuccessCondition:
|
||||
"""Condition de succès d'un nœud — comment vérifier que l'action a marché."""
|
||||
method: str = "screen_changed" # "screen_changed", "title_match", "text_visible", "none"
|
||||
expected_title: str = "" # Titre fenêtre attendu après l'action
|
||||
expected_text: str = "" # Texte qui doit apparaître
|
||||
description: str = "" # Description pour le Critic VLM (exception handler)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {"method": self.method}
|
||||
if self.expected_title:
|
||||
d["expected_title"] = self.expected_title
|
||||
if self.expected_text:
|
||||
d["expected_text"] = self.expected_text
|
||||
if self.description:
|
||||
d["description"] = self.description
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> "SuccessCondition":
|
||||
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionNode:
|
||||
"""Nœud d'exécution — une action à exécuter avec sa stratégie complète."""
|
||||
node_id: str
|
||||
action_type: str # click, type, key_combo, wait, scroll
|
||||
intent: str = "" # Intention métier (pour le logging/audit)
|
||||
|
||||
# Résolution visuelle pré-compilée
|
||||
strategy_primary: Optional[ResolutionStrategy] = None
|
||||
strategy_fallbacks: List[ResolutionStrategy] = field(default_factory=list)
|
||||
|
||||
# Données de l'action
|
||||
text: str = "" # Texte à taper
|
||||
keys: List[str] = field(default_factory=list)
|
||||
duration_ms: int = 0
|
||||
variable_name: str = "" # Si le texte est une variable
|
||||
|
||||
# Bornes d'exécution
|
||||
timeout_ms: int = 10000 # Timeout pour cette action
|
||||
max_retries: int = 1 # Nombre de retries autorisés
|
||||
retry_delay_ms: int = 2000 # Délai entre retries
|
||||
|
||||
# Vérification
|
||||
success_condition: Optional[SuccessCondition] = None
|
||||
|
||||
# Recovery
|
||||
recovery_action: str = "escape" # "escape", "undo", "close", "none"
|
||||
|
||||
# Contexte
|
||||
step_id: str = "" # Référence vers l'étape WorkflowIR
|
||||
is_optional: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {
|
||||
"node_id": self.node_id,
|
||||
"action_type": self.action_type,
|
||||
}
|
||||
if self.intent:
|
||||
d["intent"] = self.intent
|
||||
if self.strategy_primary:
|
||||
d["strategy_primary"] = self.strategy_primary.to_dict()
|
||||
if self.strategy_fallbacks:
|
||||
d["strategy_fallbacks"] = [s.to_dict() for s in self.strategy_fallbacks]
|
||||
if self.text:
|
||||
d["text"] = self.text
|
||||
if self.keys:
|
||||
d["keys"] = self.keys
|
||||
if self.duration_ms:
|
||||
d["duration_ms"] = self.duration_ms
|
||||
if self.variable_name:
|
||||
d["variable_name"] = self.variable_name
|
||||
d["timeout_ms"] = self.timeout_ms
|
||||
d["max_retries"] = self.max_retries
|
||||
if self.success_condition:
|
||||
d["success_condition"] = self.success_condition.to_dict()
|
||||
d["recovery_action"] = self.recovery_action
|
||||
if self.is_optional:
|
||||
d["is_optional"] = True
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> "ExecutionNode":
|
||||
primary = ResolutionStrategy.from_dict(d["strategy_primary"]) if d.get("strategy_primary") else None
|
||||
fallbacks = [ResolutionStrategy.from_dict(f) for f in d.get("strategy_fallbacks", [])]
|
||||
success = SuccessCondition.from_dict(d["success_condition"]) if d.get("success_condition") else None
|
||||
return cls(
|
||||
node_id=d["node_id"],
|
||||
action_type=d["action_type"],
|
||||
intent=d.get("intent", ""),
|
||||
strategy_primary=primary,
|
||||
strategy_fallbacks=fallbacks,
|
||||
text=d.get("text", ""),
|
||||
keys=d.get("keys", []),
|
||||
duration_ms=d.get("duration_ms", 0),
|
||||
variable_name=d.get("variable_name", ""),
|
||||
timeout_ms=d.get("timeout_ms", 10000),
|
||||
max_retries=d.get("max_retries", 1),
|
||||
retry_delay_ms=d.get("retry_delay_ms", 2000),
|
||||
success_condition=success,
|
||||
recovery_action=d.get("recovery_action", "escape"),
|
||||
step_id=d.get("step_id", ""),
|
||||
is_optional=d.get("is_optional", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionPlan:
|
||||
"""Plan d'exécution versionné — ce que le runtime exécute."""
|
||||
plan_id: str
|
||||
workflow_id: str # Référence vers le WorkflowIR source
|
||||
version: int = 1
|
||||
created_at: float = 0.0
|
||||
|
||||
# Nœuds d'exécution (séquence ordonnée)
|
||||
nodes: List[ExecutionNode] = field(default_factory=list)
|
||||
|
||||
# Variables à substituer avant exécution
|
||||
variables: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# Configuration globale
|
||||
domain: str = "generic"
|
||||
target_machine: str = "" # Machine cible
|
||||
target_resolution: str = "" # "1280x800", "1920x1080"
|
||||
|
||||
# Métriques de compilation
|
||||
total_nodes: int = 0
|
||||
nodes_with_ocr: int = 0 # Résolution OCR (rapide, précis)
|
||||
nodes_with_template: int = 0 # Résolution template (rapide)
|
||||
nodes_with_vlm: int = 0 # Résolution VLM (lent, dernier recours)
|
||||
estimated_duration_s: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"plan_id": self.plan_id,
|
||||
"workflow_id": self.workflow_id,
|
||||
"version": self.version,
|
||||
"created_at": self.created_at,
|
||||
"domain": self.domain,
|
||||
"target_machine": self.target_machine,
|
||||
"target_resolution": self.target_resolution,
|
||||
"variables": self.variables,
|
||||
"nodes": [n.to_dict() for n in self.nodes],
|
||||
"stats": {
|
||||
"total_nodes": self.total_nodes,
|
||||
"nodes_with_ocr": self.nodes_with_ocr,
|
||||
"nodes_with_template": self.nodes_with_template,
|
||||
"nodes_with_vlm": self.nodes_with_vlm,
|
||||
"estimated_duration_s": round(self.estimated_duration_s, 1),
|
||||
},
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> "ExecutionPlan":
|
||||
nodes = [ExecutionNode.from_dict(n) for n in d.get("nodes", [])]
|
||||
stats = d.get("stats", {})
|
||||
return cls(
|
||||
plan_id=d["plan_id"],
|
||||
workflow_id=d.get("workflow_id", ""),
|
||||
version=d.get("version", 1),
|
||||
created_at=d.get("created_at", 0),
|
||||
domain=d.get("domain", "generic"),
|
||||
target_machine=d.get("target_machine", ""),
|
||||
target_resolution=d.get("target_resolution", ""),
|
||||
variables=d.get("variables", {}),
|
||||
nodes=nodes,
|
||||
total_nodes=stats.get("total_nodes", len(nodes)),
|
||||
nodes_with_ocr=stats.get("nodes_with_ocr", 0),
|
||||
nodes_with_template=stats.get("nodes_with_template", 0),
|
||||
nodes_with_vlm=stats.get("nodes_with_vlm", 0),
|
||||
estimated_duration_s=stats.get("estimated_duration_s", 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> "ExecutionPlan":
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def save(self, directory: str) -> Path:
|
||||
dir_path = Path(directory)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
file_path = dir_path / f"{self.plan_id}.json"
|
||||
file_path.write_text(self.to_json(), encoding="utf-8")
|
||||
return file_path
|
||||
|
||||
@classmethod
|
||||
def load(cls, file_path: str) -> "ExecutionPlan":
|
||||
return cls.from_json(Path(file_path).read_text(encoding="utf-8"))
|
||||
264
tests/unit/test_execution_compiler.py
Normal file
264
tests/unit/test_execution_compiler.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Tests de l'ExecutionCompiler et de l'ExecutionPlan.
|
||||
|
||||
Vérifie que :
|
||||
- Le compilateur produit un plan déterministe depuis un WorkflowIR
|
||||
- Les stratégies de résolution sont correctement compilées (OCR > template > VLM)
|
||||
- Les timeouts, retries et recovery sont définis
|
||||
- Le plan est sérialisable et versionné
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from core.workflow.workflow_ir import WorkflowIR
|
||||
from core.workflow.execution_plan import ExecutionPlan, ExecutionNode, ResolutionStrategy, SuccessCondition
|
||||
from core.workflow.execution_compiler import ExecutionCompiler
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# ExecutionPlan — format et sérialisation
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestExecutionPlan:
|
||||
|
||||
def test_serialisation_roundtrip(self):
|
||||
plan = ExecutionPlan(
|
||||
plan_id="plan_test",
|
||||
workflow_id="wf_123",
|
||||
version=1,
|
||||
)
|
||||
plan.nodes.append(ExecutionNode(
|
||||
node_id="n1",
|
||||
action_type="click",
|
||||
intent="Cliquer sur Enregistrer",
|
||||
strategy_primary=ResolutionStrategy(method="ocr", target_text="Enregistrer"),
|
||||
strategy_fallbacks=[ResolutionStrategy(method="vlm", vlm_description="bouton Enregistrer")],
|
||||
success_condition=SuccessCondition(method="title_match", expected_title="Fichier sauvegardé"),
|
||||
))
|
||||
|
||||
json_str = plan.to_json()
|
||||
plan2 = ExecutionPlan.from_json(json_str)
|
||||
|
||||
assert plan2.plan_id == "plan_test"
|
||||
assert len(plan2.nodes) == 1
|
||||
assert plan2.nodes[0].strategy_primary.method == "ocr"
|
||||
assert len(plan2.nodes[0].strategy_fallbacks) == 1
|
||||
assert plan2.nodes[0].success_condition.method == "title_match"
|
||||
|
||||
def test_save_load(self):
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
plan = ExecutionPlan(plan_id="plan_save", workflow_id="wf_1")
|
||||
plan.nodes.append(ExecutionNode(node_id="n1", action_type="click"))
|
||||
path = plan.save(tmpdir)
|
||||
|
||||
plan2 = ExecutionPlan.load(str(path))
|
||||
assert plan2.plan_id == "plan_save"
|
||||
assert len(plan2.nodes) == 1
|
||||
finally:
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
def test_node_avec_variable(self):
|
||||
node = ExecutionNode(
|
||||
node_id="n1",
|
||||
action_type="type",
|
||||
text="{patient}",
|
||||
variable_name="patient",
|
||||
)
|
||||
d = node.to_dict()
|
||||
assert d["variable_name"] == "patient"
|
||||
assert d["text"] == "{patient}"
|
||||
|
||||
def test_node_timeout_et_retry(self):
|
||||
node = ExecutionNode(
|
||||
node_id="n1",
|
||||
action_type="click",
|
||||
timeout_ms=5000,
|
||||
max_retries=3,
|
||||
recovery_action="undo",
|
||||
)
|
||||
assert node.timeout_ms == 5000
|
||||
assert node.max_retries == 3
|
||||
assert node.recovery_action == "undo"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# ExecutionCompiler — compilation WorkflowIR → ExecutionPlan
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestExecutionCompiler:
|
||||
|
||||
def _make_ir(self):
|
||||
"""Créer un WorkflowIR de test."""
|
||||
ir = WorkflowIR.new("Test workflow", domain="generic")
|
||||
ir.add_step(
|
||||
"Ouvrir le fichier",
|
||||
actions=[
|
||||
{"type": "click", "target": "bouton Ouvrir", "anchor_hint": "Ouvrir"},
|
||||
{"type": "wait", "duration_ms": 2000},
|
||||
],
|
||||
precondition="L'application est ouverte",
|
||||
postcondition="La fenêtre Ouvrir est affichée",
|
||||
)
|
||||
ir.add_step(
|
||||
"Saisir le nom",
|
||||
actions=[
|
||||
{"type": "type", "text": "{nom_fichier}", "variable": True},
|
||||
{"type": "key_combo", "keys": ["enter"]},
|
||||
],
|
||||
)
|
||||
ir.add_variable("nom_fichier", description="Nom du fichier", default="rapport.pdf")
|
||||
return ir
|
||||
|
||||
def test_compilation_basique(self):
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
assert plan.workflow_id == ir.workflow_id
|
||||
assert plan.total_nodes == 4 # click + wait + type + key_combo
|
||||
assert plan.domain == "generic"
|
||||
|
||||
def test_click_a_strategie_resolution(self):
|
||||
"""Un clic doit avoir une stratégie primaire et des fallbacks."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
click_nodes = [n for n in plan.nodes if n.action_type == "click"]
|
||||
assert len(click_nodes) == 1
|
||||
|
||||
click = click_nodes[0]
|
||||
assert click.strategy_primary is not None
|
||||
assert click.strategy_primary.method in ("ocr", "template", "vlm")
|
||||
assert len(click.strategy_fallbacks) >= 1
|
||||
|
||||
def test_ocr_est_prioritaire(self):
|
||||
"""Quand du texte est disponible, OCR est la stratégie primaire."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
click = [n for n in plan.nodes if n.action_type == "click"][0]
|
||||
assert click.strategy_primary.method == "ocr"
|
||||
assert click.strategy_primary.target_text == "Ouvrir"
|
||||
|
||||
def test_vlm_est_fallback(self):
|
||||
"""Le VLM est toujours en dernier fallback (exception handler)."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
click = [n for n in plan.nodes if n.action_type == "click"][0]
|
||||
vlm_fallbacks = [f for f in click.strategy_fallbacks if f.method == "vlm"]
|
||||
assert len(vlm_fallbacks) >= 1
|
||||
|
||||
def test_type_a_variable(self):
|
||||
"""Une action type avec variable a le bon variable_name."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
type_nodes = [n for n in plan.nodes if n.action_type == "type"]
|
||||
assert len(type_nodes) == 1
|
||||
assert type_nodes[0].variable_name == "nom_fichier"
|
||||
|
||||
def test_wait_a_duration(self):
|
||||
"""Un wait a la bonne durée."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
wait_nodes = [n for n in plan.nodes if n.action_type == "wait"]
|
||||
assert len(wait_nodes) == 1
|
||||
assert wait_nodes[0].duration_ms == 2000
|
||||
|
||||
def test_click_a_recovery(self):
|
||||
"""Un clic a une politique de recovery."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
click = [n for n in plan.nodes if n.action_type == "click"][0]
|
||||
assert click.recovery_action in ("escape", "undo", "close", "none")
|
||||
assert click.max_retries >= 1
|
||||
|
||||
def test_postcondition_devient_success_condition(self):
|
||||
"""La postcondition du WorkflowIR devient la condition de succès du nœud."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
click = [n for n in plan.nodes if n.action_type == "click"][0]
|
||||
assert click.success_condition is not None
|
||||
assert "Ouvrir" in click.success_condition.description
|
||||
|
||||
def test_statistiques_compilation(self):
|
||||
"""Les statistiques de compilation sont correctes."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
assert plan.total_nodes == 4
|
||||
assert plan.nodes_with_ocr >= 1
|
||||
assert plan.estimated_duration_s > 0
|
||||
|
||||
def test_variables_dans_le_plan(self):
|
||||
"""Les variables du WorkflowIR sont dans le plan avec leurs valeurs par défaut."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
assert "nom_fichier" in plan.variables
|
||||
assert plan.variables["nom_fichier"] == "rapport.pdf"
|
||||
|
||||
def test_params_override_defaults(self):
|
||||
"""Les params passés au compile écrasent les valeurs par défaut."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir, params={"nom_fichier": "facture_mars.pdf"})
|
||||
|
||||
assert plan.variables["nom_fichier"] == "facture_mars.pdf"
|
||||
|
||||
def test_plan_json_roundtrip(self):
|
||||
"""Compiler → JSON → recharger → même plan."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = self._make_ir()
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
json_str = plan.to_json()
|
||||
plan2 = ExecutionPlan.from_json(json_str)
|
||||
|
||||
assert plan2.total_nodes == plan.total_nodes
|
||||
assert plan2.workflow_id == plan.workflow_id
|
||||
assert len(plan2.nodes) == len(plan.nodes)
|
||||
|
||||
def test_compilation_workflow_vide(self):
|
||||
"""Un workflow vide produit un plan vide."""
|
||||
compiler = ExecutionCompiler()
|
||||
ir = WorkflowIR.new("Vide")
|
||||
plan = compiler.compile(ir)
|
||||
|
||||
assert plan.total_nodes == 0
|
||||
assert plan.nodes == []
|
||||
|
||||
def test_plusieurs_domaines(self):
|
||||
"""Le compilateur fonctionne pour différents domaines."""
|
||||
compiler = ExecutionCompiler()
|
||||
for domain in ["tim_codage", "comptabilite", "rh_paie", "generic"]:
|
||||
ir = WorkflowIR.new("Test", domain=domain)
|
||||
ir.add_step("Action", actions=[{"type": "click", "target": "bouton"}])
|
||||
plan = compiler.compile(ir)
|
||||
assert plan.domain == domain
|
||||
Reference in New Issue
Block a user