feat: DAG executor async + intégration IA/LLM dans le VWB

- DAGExecutor : exécution workflow par graphe de dépendances,
  étapes LLM parallèles, UI séquentielles, injection ${step.result}
- LLMActionHandler : analyze_text, translate, extract_data, generate_text
  via Ollama /api/chat (qwen3-vl:8b, temperature 0.1)
- VWB palette : catégorie "IA / LLM" avec 4 actions draggables
- VWB propriétés : éditeurs pour chaque action LLM (modèle, prompt, langue)
- VWB endpoint : POST /api/v3/workflow/<id>/execute-dag
- 37 tests unitaires DAG executor (tous passent)
- Fix log spam cache workflows (info → debug)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-16 22:58:44 +01:00
parent ad15237fe0
commit 5e3865d328
11 changed files with 2911 additions and 2 deletions

View File

@@ -8,6 +8,8 @@ from .action_executor import ActionExecutor
from .target_resolver import TargetResolver, ResolvedTarget
from .error_handler import ErrorHandler, ErrorType, RecoveryStrategy
from .workflow_runner import WorkflowRunner, RunResult, RunStatus, RunnerConfig
from .dag_executor import DAGExecutor, WorkflowStep, StepType, StepStatus, DAGExecutionResult
from .llm_actions import LLMActionHandler
# Import tardif pour éviter import circulaire avec pipeline
def _get_execution_loop():
@@ -25,5 +27,12 @@ __all__ = [
'RunResult',
'RunStatus',
'RunnerConfig',
# DAG Executor — exécution parallèle basée sur un graphe de dépendances
'DAGExecutor',
'WorkflowStep',
'StepType',
'StepStatus',
'DAGExecutionResult',
'LLMActionHandler',
# ExecutionLoop accessible via import direct du module
]

View File

@@ -0,0 +1,771 @@
"""
DAGExecutor — Exécuteur de workflow basé sur un graphe de dépendances (DAG)
Remplace l'exécution linéaire étape-par-étape par un graphe de dépendances
où les tâches AI/LLM tournent en parallèle sans bloquer les actions UI.
Architecture :
- Deux ThreadPools séparés : un pour les LLM (parallèle), un pour les UI (séquentiel)
- Les étapes sans dépendances non résolues démarrent immédiatement
- Injection de résultats entre étapes via la syntaxe ${step_id.result}
- Callbacks de changement d'état pour la mise à jour de l'interface
Exemple de workflow :
1. Ouvrir OnlyOffice (UI, rapide)
2. Sélectionner texte (UI, dépend de 1)
3. Analyser texte (LLM, 10-30s, dépend de 2)
4. Traduire en français (LLM, dépend de 3, parallèle avec 5)
5. Traduire en chinois (LLM, dépend de 3, parallèle avec 4)
6. Ouvrir Gedit (UI, peut démarrer pendant que 4+5 tournent)
7. Écrire résultat FR (UI, dépend de 4 ET 6)
8. Écrire résultat CN (UI, dépend de 5 ET 7)
Auteur : Dom, Claude
Date : 16 mars 2026
"""
import logging
import re
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from copy import deepcopy
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Types d'étapes et statuts
# =============================================================================
class StepType(str, Enum):
"""Type d'étape dans le workflow."""
UI_ACTION = "ui_action" # Clic, frappe, etc. — rapide, séquentiel sur la cible
LLM_CALL = "llm_call" # Appel API Ollama — lent, peut tourner en arrière-plan
WAIT = "wait" # Attente explicite / délai
CONDITION = "condition" # Branchement conditionnel basé sur un résultat
class StepStatus(str, Enum):
"""Statut d'exécution d'une étape."""
PENDING = "pending" # En attente de dépendances
READY = "ready" # Toutes les dépendances résolues
RUNNING = "running" # En cours d'exécution
COMPLETED = "completed" # Terminée avec succès
FAILED = "failed" # Échouée
SKIPPED = "skipped" # Ignorée (dépendance échouée ou condition fausse)
# =============================================================================
# Modèle d'une étape de workflow
# =============================================================================
@dataclass
class WorkflowStep:
"""Représente une étape individuelle du workflow DAG.
Attributes:
step_id: Identifiant unique de l'étape
step_type: Type d'étape (UI, LLM, etc.)
action: Paramètres de l'action à exécuter
depends_on: Liste des step_id dont cette étape dépend
status: Statut courant
result: Résultat de l'exécution (ex: texte traduit)
error: Message d'erreur en cas d'échec
started_at: Timestamp de début d'exécution
completed_at: Timestamp de fin d'exécution
"""
step_id: str
step_type: StepType
action: Dict[str, Any] = field(default_factory=dict)
depends_on: List[str] = field(default_factory=list)
status: StepStatus = StepStatus.PENDING
result: Any = None
error: Optional[str] = None
started_at: Optional[float] = None
completed_at: Optional[float] = None
def duration(self) -> Optional[float]:
"""Durée d'exécution en secondes, ou None si pas encore terminée."""
if self.started_at and self.completed_at:
return self.completed_at - self.started_at
return None
def to_dict(self) -> Dict[str, Any]:
"""Sérialiser l'étape pour l'API / les callbacks UI."""
return {
"step_id": self.step_id,
"step_type": self.step_type.value,
"action": self.action,
"depends_on": self.depends_on,
"status": self.status.value,
"result": self.result,
"error": self.error,
"started_at": self.started_at,
"completed_at": self.completed_at,
"duration": self.duration(),
}
# =============================================================================
# Résultat global du workflow
# =============================================================================
@dataclass
class DAGExecutionResult:
"""Résultat complet de l'exécution d'un workflow DAG."""
success: bool
steps: Dict[str, Dict[str, Any]] = field(default_factory=dict)
results: Dict[str, Any] = field(default_factory=dict)
errors: List[str] = field(default_factory=list)
duration_seconds: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"steps": self.steps,
"results": self.results,
"errors": self.errors,
"duration_seconds": round(self.duration_seconds, 3),
}
# =============================================================================
# Patron de substitution pour l'injection de résultats
# =============================================================================
# Reconnaît ${step_id.result} ou ${step_id.result.champ}
_RESULT_REF_PATTERN = re.compile(r"\$\{(\w+)\.result(?:\.(\w+))?\}")
# =============================================================================
# DAGExecutor — Cœur de l'exécution parallèle
# =============================================================================
class DAGExecutor:
"""Exécuteur de workflow basé sur un graphe de dépendances.
Les étapes sans dépendances non résolues démarrent immédiatement.
Les étapes LLM tournent dans un ThreadPool dédié et n'empêchent pas
les étapes UI de continuer sur leur propre pool (séquentiel, 1 worker).
Args:
max_llm_workers: Nombre max de tâches LLM en parallèle
max_ui_workers: Nombre max de tâches UI en parallèle (1 = séquentiel)
llm_handler: Handler pour les appels LLM (injection de dépendance)
ui_handler: Handler pour les actions UI (injection de dépendance)
"""
def __init__(
self,
max_llm_workers: int = 2,
max_ui_workers: int = 1,
llm_handler: Optional[Any] = None,
ui_handler: Optional[Callable] = None,
):
self._max_llm_workers = max_llm_workers
self._max_ui_workers = max_ui_workers
self._llm_pool: Optional[ThreadPoolExecutor] = None
self._ui_pool: Optional[ThreadPoolExecutor] = None
# Handlers d'exécution — injectables pour les tests
self._llm_handler = llm_handler
self._ui_handler = ui_handler
# État du workflow
self._steps: Dict[str, WorkflowStep] = {}
self._results: Dict[str, Any] = {} # step_id → résultat
self._futures: Dict[str, Future] = {} # step_id → future en cours
# Synchronisation
self._lock = threading.Lock()
self._all_done = threading.Event()
self._cancelled = False
# Callbacks pour la mise à jour de l'interface
self._on_step_change_callbacks: List[Callable] = []
# -----------------------------------------------------------------
# Chargement du workflow
# -----------------------------------------------------------------
def load_workflow(self, steps: List[WorkflowStep]) -> None:
"""Charger les étapes du workflow et valider le DAG.
Raises:
ValueError: Si le graphe contient un cycle ou des dépendances invalides
"""
with self._lock:
self._steps.clear()
self._results.clear()
self._futures.clear()
self._cancelled = False
self._all_done.clear()
for step in steps:
if step.step_id in self._steps:
raise ValueError(
f"Identifiant d'étape dupliqué : '{step.step_id}'"
)
self._steps[step.step_id] = step
# Valider que toutes les dépendances référencent des étapes existantes
for step in self._steps.values():
for dep_id in step.depends_on:
if dep_id not in self._steps:
raise ValueError(
f"L'étape '{step.step_id}' dépend de '{dep_id}' "
f"qui n'existe pas dans le workflow"
)
# Détecter les cycles via tri topologique (Kahn)
self._validate_no_cycles()
logger.info(
"Workflow chargé : %d étapes", len(self._steps)
)
def _validate_no_cycles(self) -> None:
"""Vérifie l'absence de cycle dans le DAG (algorithme de Kahn).
Raises:
ValueError: Si un cycle est détecté
"""
in_degree: Dict[str, int] = {
sid: len(s.depends_on) for sid, s in self._steps.items()
}
queue = [sid for sid, deg in in_degree.items() if deg == 0]
visited = 0
while queue:
current = queue.pop(0)
visited += 1
# Trouver les étapes qui dépendent de current
for sid, step in self._steps.items():
if current in step.depends_on:
in_degree[sid] -= 1
if in_degree[sid] == 0:
queue.append(sid)
if visited != len(self._steps):
raise ValueError(
"Cycle détecté dans le graphe de dépendances. "
"Le workflow doit être un DAG (graphe acyclique dirigé)."
)
# -----------------------------------------------------------------
# Exécution principale
# -----------------------------------------------------------------
def execute(self, timeout: float = 300.0) -> DAGExecutionResult:
"""Exécuter le workflow en respectant les dépendances.
Algorithme :
1. Trouver toutes les étapes READY (dépendances résolues)
2. Soumettre les LLM au llm_pool, les UI au ui_pool
3. Quand une étape termine, ré-évaluer les dépendances
4. Répéter jusqu'à ce que tout soit terminé ou échoué
Args:
timeout: Timeout global en secondes (défaut 5 minutes)
Returns:
DAGExecutionResult avec le statut de toutes les étapes
"""
start_time = time.monotonic()
# Créer les pools (à chaque exécution pour éviter les problèmes
# de réutilisation après shutdown)
self._llm_pool = ThreadPoolExecutor(
max_workers=self._max_llm_workers,
thread_name_prefix="dag-llm",
)
self._ui_pool = ThreadPoolExecutor(
max_workers=self._max_ui_workers,
thread_name_prefix="dag-ui",
)
try:
# Lancer la boucle initiale
self._schedule_ready_steps()
# Attendre que tout soit terminé ou le timeout
self._all_done.wait(timeout=timeout)
elapsed = time.monotonic() - start_time
# Vérifier si timeout
if not self._all_done.is_set():
logger.warning("Timeout atteint après %.1fs", elapsed)
self._cancel_remaining("Timeout global atteint")
# Construire le résultat
return self._build_result(elapsed)
finally:
self._llm_pool.shutdown(wait=False)
self._ui_pool.shutdown(wait=False)
self._llm_pool = None
self._ui_pool = None
# -----------------------------------------------------------------
# Planification des étapes
# -----------------------------------------------------------------
def _schedule_ready_steps(self) -> None:
"""Identifier et soumettre les étapes prêtes à l'exécution."""
with self._lock:
if self._cancelled:
return
ready_steps = self._find_ready_steps()
if not ready_steps and self._is_all_done():
self._all_done.set()
return
for step in ready_steps:
step.status = StepStatus.RUNNING
step.started_at = time.monotonic()
self._notify_step_change(step)
# Choisir le pool selon le type d'étape
pool = self._get_pool_for_step(step)
future = pool.submit(self._execute_step, step)
self._futures[step.step_id] = future
def _find_ready_steps(self) -> List[WorkflowStep]:
"""Trouver toutes les étapes dont les dépendances sont résolues.
Une étape est prête si :
- Son statut est PENDING
- Toutes ses dépendances sont COMPLETED
"""
ready = []
for step in self._steps.values():
if step.status != StepStatus.PENDING:
continue
if self._check_dependencies(step):
step.status = StepStatus.READY
ready.append(step)
return ready
def _check_dependencies(self, step: WorkflowStep) -> bool:
"""Vérifie si toutes les dépendances d'une étape sont résolues.
Returns:
True si toutes les dépendances sont COMPLETED
"""
for dep_id in step.depends_on:
dep = self._steps.get(dep_id)
if dep is None:
return False
if dep.status != StepStatus.COMPLETED:
return False
return True
def _is_all_done(self) -> bool:
"""Vérifie si toutes les étapes sont terminées (succès, échec ou ignorées)."""
return all(
s.status in (StepStatus.COMPLETED, StepStatus.FAILED, StepStatus.SKIPPED)
for s in self._steps.values()
)
def _get_pool_for_step(self, step: WorkflowStep) -> ThreadPoolExecutor:
"""Retourne le pool approprié selon le type d'étape."""
if step.step_type == StepType.LLM_CALL:
return self._llm_pool
# UI_ACTION, WAIT, CONDITION → pool UI (séquentiel)
return self._ui_pool
# -----------------------------------------------------------------
# Exécution d'une étape
# -----------------------------------------------------------------
def _execute_step(self, step: WorkflowStep) -> None:
"""Exécute une étape dans le pool approprié.
Appelé par le ThreadPoolExecutor. Gère les erreurs et
déclenche la re-planification après complétion.
"""
if self._cancelled:
return
try:
logger.info(
"Démarrage étape '%s' (type=%s)",
step.step_id, step.step_type.value,
)
# Injecter les résultats des dépendances dans les paramètres
resolved_action = self._inject_results(step)
# Dispatcher selon le type
if step.step_type == StepType.LLM_CALL:
result = self._execute_llm_step(step, resolved_action)
elif step.step_type == StepType.UI_ACTION:
result = self._execute_ui_step(step, resolved_action)
elif step.step_type == StepType.WAIT:
result = self._execute_wait_step(step, resolved_action)
elif step.step_type == StepType.CONDITION:
result = self._execute_condition_step(step, resolved_action)
else:
raise ValueError(f"Type d'étape inconnu : {step.step_type}")
# Succès
with self._lock:
step.status = StepStatus.COMPLETED
step.result = result
step.completed_at = time.monotonic()
self._results[step.step_id] = result
logger.info(
"Étape '%s' terminée en %.2fs",
step.step_id,
step.duration() or 0,
)
except Exception as exc:
logger.error(
"Étape '%s' échouée : %s", step.step_id, exc, exc_info=True
)
with self._lock:
step.status = StepStatus.FAILED
step.error = str(exc)
step.completed_at = time.monotonic()
# Marquer les dépendants comme SKIPPED
self._skip_dependents(step.step_id)
finally:
self._notify_step_change(step)
# Re-planifier les étapes devenues prêtes
self._schedule_ready_steps()
def _execute_llm_step(
self, step: WorkflowStep, action: Dict[str, Any]
) -> Any:
"""Exécute un appel LLM via le handler configuré.
Args:
step: L'étape à exécuter
action: Paramètres de l'action avec résultats injectés
Returns:
Le résultat du LLM (texte, dict, etc.)
"""
if self._llm_handler is None:
raise RuntimeError(
"Aucun handler LLM configuré. "
"Passez un LLMActionHandler au constructeur ou via set_llm_handler()."
)
# Le handler LLM reçoit l'action et le contexte des résultats
context = {
"results": dict(self._results),
"step_id": step.step_id,
}
return self._llm_handler.execute(action, context)
def _execute_ui_step(
self, step: WorkflowStep, action: Dict[str, Any]
) -> Any:
"""Exécute une action UI via le handler configuré.
Args:
step: L'étape à exécuter
action: Paramètres de l'action avec résultats injectés
Returns:
Le résultat de l'action UI
"""
if self._ui_handler is not None:
return self._ui_handler(action)
# Comportement par défaut : log + retour des paramètres
logger.info(
"Action UI '%s' : %s (pas de handler configuré, simulation)",
step.step_id, action.get("type", "unknown"),
)
return {"simulated": True, "action": action}
def _execute_wait_step(
self, step: WorkflowStep, action: Dict[str, Any]
) -> Any:
"""Exécute une étape d'attente.
Args:
step: L'étape à exécuter
action: Doit contenir 'duration' en secondes
Returns:
Dict avec la durée effective
"""
duration = float(action.get("duration", 1.0))
logger.info("Attente de %.1fs (étape '%s')", duration, step.step_id)
time.sleep(duration)
return {"waited": duration}
def _execute_condition_step(
self, step: WorkflowStep, action: Dict[str, Any]
) -> Any:
"""Exécute une étape conditionnelle.
Évalue une condition simple basée sur les résultats des dépendances.
Le champ 'condition' de l'action doit contenir une expression évaluable.
Args:
step: L'étape à exécuter
action: Doit contenir 'condition' et optionnellement 'skip_on_false'
Returns:
True/False selon le résultat de la condition
"""
condition = action.get("condition", "True")
# Contexte d'évaluation sécurisé : uniquement les résultats
eval_context = {"results": dict(self._results)}
try:
result = bool(eval(condition, {"__builtins__": {}}, eval_context))
except Exception as exc:
logger.warning(
"Erreur d'évaluation de condition pour '%s' : %s",
step.step_id, exc,
)
result = False
logger.info(
"Condition '%s' évaluée à %s", step.step_id, result
)
# Si la condition est fausse et skip_on_false est activé,
# marquer les dépendants pour skip
if not result and action.get("skip_on_false", False):
self._skip_dependents(step.step_id)
return result
# -----------------------------------------------------------------
# Injection de résultats
# -----------------------------------------------------------------
def _inject_results(self, step: WorkflowStep) -> Dict[str, Any]:
"""Injecte les résultats des dépendances dans les paramètres de l'étape.
Parcourt récursivement les valeurs de l'action et remplace
les références ${step_id.result} par les résultats effectifs.
Syntaxe supportée :
- ${step_3.result} → résultat complet de l'étape 3
- ${step_3.result.text} → champ 'text' du résultat de l'étape 3
Args:
step: L'étape dont les paramètres doivent être résolus
Returns:
Copie de l'action avec les références remplacées
"""
action = deepcopy(step.action)
return self._resolve_references(action)
def _resolve_references(self, obj: Any) -> Any:
"""Résout récursivement les références ${...} dans un objet."""
if isinstance(obj, str):
return self._resolve_string_references(obj)
elif isinstance(obj, dict):
return {k: self._resolve_references(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self._resolve_references(item) for item in obj]
return obj
def _resolve_string_references(self, text: str) -> Any:
"""Résout les références dans une chaîne de caractères.
Si la chaîne entière est une référence unique, retourne la valeur brute
(pas nécessairement une chaîne). Sinon, remplace les références dans la
chaîne par leur représentation textuelle.
"""
# Cas spécial : la chaîne entière est une seule référence
match = _RESULT_REF_PATTERN.fullmatch(text)
if match:
return self._get_referenced_value(match.group(1), match.group(2))
# Cas général : remplacer les références dans la chaîne
def replacer(m: re.Match) -> str:
value = self._get_referenced_value(m.group(1), m.group(2))
return str(value) if value is not None else m.group(0)
return _RESULT_REF_PATTERN.sub(replacer, text)
def _get_referenced_value(
self, step_id: str, field_name: Optional[str]
) -> Any:
"""Récupère la valeur référencée par un step_id et un champ optionnel."""
value = self._results.get(step_id)
if value is None:
logger.warning(
"Référence à un résultat inexistant : %s", step_id
)
return None
if field_name and isinstance(value, dict):
return value.get(field_name)
return value
# -----------------------------------------------------------------
# Gestion des échecs et annulation
# -----------------------------------------------------------------
def _skip_dependents(self, failed_step_id: str) -> None:
"""Marque comme SKIPPED toutes les étapes qui dépendent d'une étape échouée.
Propagation récursive : si A échoue, B (qui dépend de A) est SKIPPED,
et C (qui dépend de B) est aussi SKIPPED.
"""
with self._lock:
to_skip = set()
# Trouver les dépendants directs
for sid, step in self._steps.items():
if (
failed_step_id in step.depends_on
and step.status in (StepStatus.PENDING, StepStatus.READY)
):
to_skip.add(sid)
# Propager récursivement
changed = True
while changed:
changed = False
for sid, step in self._steps.items():
if sid in to_skip:
continue
if step.status not in (StepStatus.PENDING, StepStatus.READY):
continue
if any(dep in to_skip for dep in step.depends_on):
to_skip.add(sid)
changed = True
# Appliquer le skip
for sid in to_skip:
step = self._steps[sid]
step.status = StepStatus.SKIPPED
step.error = f"Ignorée car l'étape '{failed_step_id}' a échoué"
step.completed_at = time.monotonic()
logger.info("Étape '%s' ignorée (dépendance échouée)", sid)
self._notify_step_change(step)
def cancel(self) -> None:
"""Annule l'exécution en cours.
Les étapes déjà en cours termineront naturellement, mais aucune
nouvelle étape ne sera planifiée.
"""
logger.info("Annulation du workflow demandée")
with self._lock:
self._cancelled = True
self._cancel_remaining("Annulé par l'utilisateur")
self._all_done.set()
def _cancel_remaining(self, reason: str) -> None:
"""Annule toutes les étapes non terminées.
Doit être appelé avec le lock déjà acquis.
"""
for step in self._steps.values():
if step.status in (StepStatus.PENDING, StepStatus.READY):
step.status = StepStatus.SKIPPED
step.error = reason
step.completed_at = time.monotonic()
self._notify_step_change(step)
# -----------------------------------------------------------------
# Observation et callbacks
# -----------------------------------------------------------------
def on_step_change(self, callback: Callable[[WorkflowStep], None]) -> None:
"""Enregistre un callback appelé à chaque changement d'état d'une étape.
Le callback reçoit l'objet WorkflowStep modifié.
Args:
callback: Fonction à appeler lors d'un changement
"""
self._on_step_change_callbacks.append(callback)
def _notify_step_change(self, step: WorkflowStep) -> None:
"""Notifie tous les callbacks enregistrés d'un changement d'état."""
for cb in self._on_step_change_callbacks:
try:
cb(step)
except Exception as exc:
logger.error(
"Erreur dans le callback de changement d'état : %s", exc
)
def get_status(self) -> Dict[str, Any]:
"""Retourne l'état de toutes les étapes du workflow.
Returns:
Dict avec les clés 'steps', 'results', 'summary'
"""
with self._lock:
steps_status = {
sid: step.to_dict() for sid, step in self._steps.items()
}
# Compteurs par statut
summary = {}
for status in StepStatus:
count = sum(
1 for s in self._steps.values() if s.status == status
)
if count > 0:
summary[status.value] = count
return {
"steps": steps_status,
"results": dict(self._results),
"summary": summary,
"cancelled": self._cancelled,
}
# -----------------------------------------------------------------
# Méthodes utilitaires
# -----------------------------------------------------------------
def set_llm_handler(self, handler: Any) -> None:
"""Configure le handler LLM après construction."""
self._llm_handler = handler
def set_ui_handler(self, handler: Callable) -> None:
"""Configure le handler UI après construction."""
self._ui_handler = handler
def _build_result(self, elapsed: float) -> DAGExecutionResult:
"""Construit le résultat final de l'exécution."""
errors = [
f"Étape '{sid}' : {step.error}"
for sid, step in self._steps.items()
if step.status == StepStatus.FAILED and step.error
]
all_success = all(
s.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
for s in self._steps.values()
)
# Un workflow avec des étapes SKIPPED à cause d'un échec n'est pas un succès
has_failures = any(
s.status == StepStatus.FAILED for s in self._steps.values()
)
return DAGExecutionResult(
success=all_success and not has_failures,
steps={sid: step.to_dict() for sid, step in self._steps.items()},
results=dict(self._results),
errors=errors,
duration_seconds=elapsed,
)

View File

@@ -0,0 +1,431 @@
"""
LLMActionHandler — Gestionnaire d'actions LLM pour les workflows DAG
Gère les appels LLM via l'API Ollama /api/chat pour les étapes de workflow.
Chaque action est un appel synchrone (bloquant) qui sera exécuté dans
le ThreadPool LLM du DAGExecutor.
Actions supportées :
- analyze_text : Analyser / résumer un texte
- translate : Traduire un texte vers une langue cible
- extract_data : Extraire des données structurées d'un texte
- generate_text : Générer du texte à partir d'un prompt
Auteur : Dom, Claude
Date : 16 mars 2026
"""
import json
import logging
from typing import Any, Dict, Optional
import requests
logger = logging.getLogger(__name__)
class LLMActionHandler:
"""Gestionnaire d'appels LLM pour les étapes de workflow.
Utilise l'API Ollama /api/chat (mode conversationnel) pour toutes
les interactions avec le modèle de langage.
Args:
ollama_endpoint: URL de l'API Ollama
model: Nom du modèle à utiliser
temperature: Température de génération par défaut
timeout: Timeout par appel en secondes
"""
def __init__(
self,
ollama_endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
temperature: float = 0.1,
timeout: int = 120,
):
self.endpoint = ollama_endpoint.rstrip("/")
self.model = model
self.temperature = temperature
self.timeout = timeout
# -----------------------------------------------------------------
# Dispatcher principal
# -----------------------------------------------------------------
def execute(self, action: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Dispatcher vers la bonne action LLM.
Args:
action: Dict contenant au minimum 'llm_action' (nom de l'action)
et les paramètres spécifiques à l'action
context: Contexte d'exécution (résultats précédents, step_id, etc.)
Returns:
Résultat de l'action (texte, dict, etc.)
Raises:
ValueError: Si l'action LLM est inconnue
RuntimeError: Si l'appel à Ollama échoue
"""
llm_action = action.get("llm_action", "")
dispatch = {
"analyze_text": self._dispatch_analyze,
"translate": self._dispatch_translate,
"extract_data": self._dispatch_extract,
"generate_text": self._dispatch_generate,
}
handler = dispatch.get(llm_action)
if handler is None:
raise ValueError(
f"Action LLM inconnue : '{llm_action}'. "
f"Actions supportées : {list(dispatch.keys())}"
)
return handler(action, context)
# -----------------------------------------------------------------
# Actions LLM
# -----------------------------------------------------------------
def analyze_text(
self,
text: str,
instruction: str = "Analyse et résume ce texte.",
model: Optional[str] = None,
temperature: Optional[float] = None,
) -> str:
"""Analyser ou résumer un texte.
Args:
text: Texte à analyser
instruction: Instruction pour l'analyse
model: Modèle spécifique (sinon modèle par défaut)
temperature: Température spécifique
Returns:
Texte de l'analyse
"""
system_prompt = (
"Tu es un assistant d'analyse de texte. "
"Réponds de manière concise et structurée."
)
user_message = f"{instruction}\n\nTexte :\n{text}"
return self._chat(
system_prompt=system_prompt,
user_message=user_message,
model=model,
temperature=temperature,
)
def translate(
self,
text: str,
target_lang: str,
source_lang: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
) -> str:
"""Traduire un texte vers une langue cible.
Args:
text: Texte à traduire
target_lang: Langue cible (ex: "français", "chinois", "english")
source_lang: Langue source (détection auto si None)
model: Modèle spécifique
temperature: Température spécifique
Returns:
Texte traduit
"""
system_prompt = (
"Tu es un traducteur professionnel. "
"Traduis le texte fidèlement, sans ajouter d'explication. "
"Retourne uniquement la traduction."
)
if source_lang:
user_message = (
f"Traduis le texte suivant du {source_lang} "
f"vers le {target_lang} :\n\n{text}"
)
else:
user_message = (
f"Traduis le texte suivant en {target_lang} :\n\n{text}"
)
return self._chat(
system_prompt=system_prompt,
user_message=user_message,
model=model,
temperature=temperature,
)
def extract_data(
self,
text: str,
schema: Dict[str, Any],
model: Optional[str] = None,
temperature: Optional[float] = None,
) -> Dict[str, Any]:
"""Extraire des données structurées d'un texte.
Args:
text: Texte source
schema: Schéma des données à extraire (clés attendues + descriptions)
model: Modèle spécifique
temperature: Température spécifique
Returns:
Dict avec les données extraites
"""
schema_desc = json.dumps(schema, ensure_ascii=False, indent=2)
system_prompt = (
"Tu es un extracteur de données. "
"Extrais les informations demandées du texte et retourne "
"un JSON valide correspondant au schéma fourni. "
"Retourne UNIQUEMENT le JSON, sans explication."
)
user_message = (
f"Schéma attendu :\n{schema_desc}\n\n"
f"Texte source :\n{text}"
)
response = self._chat(
system_prompt=system_prompt,
user_message=user_message,
model=model,
temperature=temperature,
force_json=True,
)
# Parser le JSON de la réponse
try:
return json.loads(response)
except json.JSONDecodeError:
# Tenter d'extraire le JSON de la réponse
logger.warning(
"Réponse LLM non-JSON pour extract_data, tentative d'extraction"
)
return self._try_extract_json(response)
def generate_text(
self,
prompt: str,
context: str = "",
model: Optional[str] = None,
temperature: Optional[float] = None,
) -> str:
"""Générer du texte à partir d'un prompt.
Args:
prompt: Instruction de génération
context: Contexte additionnel
model: Modèle spécifique
temperature: Température spécifique
Returns:
Texte généré
"""
system_prompt = (
"Tu es un assistant de rédaction. "
"Génère le contenu demandé de manière claire et précise."
)
user_message = prompt
if context:
user_message = f"Contexte :\n{context}\n\nInstruction :\n{prompt}"
return self._chat(
system_prompt=system_prompt,
user_message=user_message,
model=model,
temperature=temperature,
)
# -----------------------------------------------------------------
# Dispatchers internes (adaptent les paramètres d'action)
# -----------------------------------------------------------------
def _dispatch_analyze(
self, action: Dict[str, Any], context: Dict[str, Any]
) -> str:
"""Dispatche une action analyze_text depuis les paramètres du workflow."""
return self.analyze_text(
text=action.get("text", ""),
instruction=action.get("instruction", "Analyse et résume ce texte."),
model=action.get("model"),
temperature=action.get("temperature"),
)
def _dispatch_translate(
self, action: Dict[str, Any], context: Dict[str, Any]
) -> str:
"""Dispatche une action translate depuis les paramètres du workflow."""
return self.translate(
text=action.get("text", ""),
target_lang=action.get("target_lang", "français"),
source_lang=action.get("source_lang"),
model=action.get("model"),
temperature=action.get("temperature"),
)
def _dispatch_extract(
self, action: Dict[str, Any], context: Dict[str, Any]
) -> Dict[str, Any]:
"""Dispatche une action extract_data depuis les paramètres du workflow."""
return self.extract_data(
text=action.get("text", ""),
schema=action.get("schema", {}),
model=action.get("model"),
temperature=action.get("temperature"),
)
def _dispatch_generate(
self, action: Dict[str, Any], context: Dict[str, Any]
) -> str:
"""Dispatche une action generate_text depuis les paramètres du workflow."""
return self.generate_text(
prompt=action.get("prompt", ""),
context=action.get("context", ""),
model=action.get("model"),
temperature=action.get("temperature"),
)
# -----------------------------------------------------------------
# Communication avec Ollama via /api/chat
# -----------------------------------------------------------------
def _chat(
self,
system_prompt: str,
user_message: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
force_json: bool = False,
) -> str:
"""Appel à l'API /api/chat d'Ollama.
Args:
system_prompt: Message système
user_message: Message utilisateur
model: Modèle (défaut: self.model)
temperature: Température (défaut: self.temperature)
force_json: Forcer la sortie JSON
Returns:
Contenu de la réponse du modèle
Raises:
RuntimeError: Si l'appel échoue
"""
effective_model = model or self.model
effective_temp = temperature if temperature is not None else self.temperature
# Pour Qwen3, désactiver le mode thinking pour des réponses directes
effective_user_message = user_message
if "qwen" in effective_model.lower():
effective_user_message = f"/nothink {user_message}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": effective_user_message},
]
payload = {
"model": effective_model,
"messages": messages,
"stream": False,
"options": {
"temperature": effective_temp,
},
}
if force_json:
payload["format"] = "json"
url = f"{self.endpoint}/api/chat"
try:
response = requests.post(
url,
json=payload,
timeout=self.timeout,
)
if response.status_code != 200:
raise RuntimeError(
f"Ollama /api/chat a retourné HTTP {response.status_code} : "
f"{response.text[:500]}"
)
data = response.json()
content = data.get("message", {}).get("content", "")
if not content:
raise RuntimeError(
"Ollama a retourné une réponse vide"
)
return content
except requests.exceptions.Timeout:
raise RuntimeError(
f"Timeout de {self.timeout}s dépassé pour l'appel LLM "
f"(modèle: {effective_model})"
)
except requests.exceptions.ConnectionError:
raise RuntimeError(
f"Impossible de se connecter à Ollama sur {self.endpoint}. "
f"Vérifiez que le service est lancé."
)
# -----------------------------------------------------------------
# Utilitaires
# -----------------------------------------------------------------
@staticmethod
def _try_extract_json(text: str) -> Dict[str, Any]:
"""Tente d'extraire un objet JSON d'un texte libre.
Cherche le premier { et le dernier } pour isoler le JSON.
"""
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
pass
logger.error("Impossible d'extraire du JSON de la réponse LLM")
return {"raw_response": text, "_parse_error": True}
def check_connection(self) -> bool:
"""Vérifie la connexion à Ollama et la disponibilité du modèle.
Returns:
True si Ollama répond et le modèle est disponible
"""
try:
response = requests.get(
f"{self.endpoint}/api/tags", timeout=5
)
if response.status_code == 200:
models = response.json().get("models", [])
model_names = [m["name"] for m in models]
if self.model in model_names:
return True
logger.warning(
"Modèle '%s' non trouvé dans Ollama. "
"Modèles disponibles : %s",
self.model,
model_names,
)
return False
except Exception as exc:
logger.warning(
"Impossible de se connecter à Ollama sur %s : %s",
self.endpoint,
exc,
)
return False

File diff suppressed because it is too large Load Diff

View File

@@ -17,4 +17,10 @@ from . import execute
from . import match # Matching sémantique des workflows
from . import review # Review/Validation de workflows importés
# DAG Executor — exécution parallèle avec étapes LLM
try:
from . import dag_execute # noqa: F401
except ImportError as e:
print(f"⚠️ Module dag_execute désactivé: {e}")
__all__ = ['api_v3_bp']

View File

@@ -0,0 +1,340 @@
"""
API v3 - Exécution DAG de Workflows avec étapes LLM
Convertit un workflow VWB (nœuds + edges) en DAGExecutor steps
et lance l'exécution parallèle (UI séquentiel, LLM parallèle).
POST /api/v3/workflow/<id>/execute-dag → Lance l'exécution DAG
GET /api/v3/workflow/<id>/dag-status → Statut de l'exécution en cours
Auteur : Dom, Claude — 16 mars 2026
"""
import json
import logging
import sys
import traceback
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from flask import jsonify, request
from . import api_v3_bp
logger = logging.getLogger(__name__)
# Ajouter le répertoire racine pour les imports core
_ROOT = str(Path(__file__).resolve().parent.parent.parent.parent)
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from core.execution.dag_executor import (
DAGExecutionResult,
DAGExecutor,
StepStatus,
StepType,
WorkflowStep,
)
from core.execution.llm_actions import LLMActionHandler
# ---------------------------------------------------------------------------
# Types d'actions VWB → StepType du DAGExecutor
# ---------------------------------------------------------------------------
# Les action_types VWB qui correspondent à des appels LLM
_LLM_ACTION_TYPES = {
"llm_analyze",
"llm_translate",
"llm_extract_data",
"llm_generate",
}
# Mapping action_type VWB → llm_action du LLMActionHandler
_LLM_ACTION_MAP = {
"llm_analyze": "analyze_text",
"llm_translate": "translate",
"llm_extract_data": "extract_data",
"llm_generate": "generate_text",
}
# Actions VWB de type attente
_WAIT_ACTION_TYPES = {"wait_for_anchor"}
# Actions VWB de type condition
_CONDITION_ACTION_TYPES = {"visual_condition"}
def _classify_step_type(action_type: str) -> StepType:
"""Détermine le StepType DAG à partir du action_type VWB."""
if action_type in _LLM_ACTION_TYPES:
return StepType.LLM_CALL
if action_type in _WAIT_ACTION_TYPES:
return StepType.WAIT
if action_type in _CONDITION_ACTION_TYPES:
return StepType.CONDITION
return StepType.UI_ACTION
def _build_llm_action(action_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""Construit le dict d'action LLM attendu par le LLMActionHandler.
Ajoute la clé 'llm_action' et recopie les paramètres pertinents.
"""
llm_action = _LLM_ACTION_MAP.get(action_type)
if not llm_action:
raise ValueError(f"Type d'action LLM inconnu : {action_type}")
action = {"llm_action": llm_action}
# Copier les paramètres pertinents sans modification
for key in ("text", "instruction", "model", "temperature",
"target_lang", "source_lang", "schema", "prompt", "context"):
if key in parameters and parameters[key]:
val = parameters[key]
# Le schema peut être une chaîne JSON — le parser
if key == "schema" and isinstance(val, str):
try:
val = json.loads(val)
except json.JSONDecodeError:
pass
action[key] = val
return action
# ---------------------------------------------------------------------------
# Conversion VWB workflow → DAG steps
# ---------------------------------------------------------------------------
def _convert_vwb_to_dag_steps(
steps_data: List[Dict[str, Any]],
edges_data: List[Dict[str, Any]],
) -> List[WorkflowStep]:
"""Convertit les nœuds et edges VWB en liste de WorkflowStep DAG.
Les edges définissent les dépendances : si un edge va de A vers B,
alors B dépend de A.
Args:
steps_data: Liste de dicts (step.to_dict() depuis le modèle SQLAlchemy)
edges_data: Liste de dicts {"source": "step_id_A", "target": "step_id_B"}
Returns:
Liste de WorkflowStep prêtes à être chargées dans le DAGExecutor
"""
# Construire le mapping des dépendances depuis les edges
depends_map: Dict[str, List[str]] = {}
for edge in edges_data:
source = edge.get("source", "")
target = edge.get("target", "")
if source and target:
depends_map.setdefault(target, []).append(source)
dag_steps = []
for step_data in steps_data:
step_id = step_data["id"]
action_type = step_data["action_type"]
parameters = step_data.get("parameters", {})
step_type = _classify_step_type(action_type)
depends_on = depends_map.get(step_id, [])
# Construire l'action selon le type
if step_type == StepType.LLM_CALL:
action = _build_llm_action(action_type, parameters)
else:
# Pour les actions UI, passer les paramètres tels quels
action = {"type": action_type, **parameters}
dag_step = WorkflowStep(
step_id=step_id,
step_type=step_type,
action=action,
depends_on=depends_on,
)
dag_steps.append(dag_step)
return dag_steps
# ---------------------------------------------------------------------------
# Instance globale du dernier exécuteur (pour le status polling)
# ---------------------------------------------------------------------------
_current_executor: Optional[DAGExecutor] = None
_last_result: Optional[DAGExecutionResult] = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@api_v3_bp.route('/workflow/<workflow_id>/execute-dag', methods=['POST'])
def execute_dag(workflow_id: str):
"""
Lance l'exécution DAG d'un workflow VWB.
Les étapes LLM (llm_analyze, llm_translate, llm_extract_data, llm_generate)
sont exécutées en parallèle via Ollama. Les étapes UI restent séquentielles.
Body (optionnel) :
{
"edges": [{"source": "step_A", "target": "step_B"}, ...],
"timeout": 300,
"model": "qwen3-vl:8b",
"ollama_endpoint": "http://localhost:11434"
}
Response :
{
"success": true/false,
"execution": { "success": ..., "steps": {...}, "results": {...}, ... }
}
"""
global _current_executor, _last_result
try:
from db.models import Workflow, Step
workflow = Workflow.query.get(workflow_id)
if not workflow:
return jsonify({
'success': False,
'error': f"Workflow '{workflow_id}' non trouvé"
}), 404
# Récupérer les étapes depuis la BDD
steps_db = Step.query.filter_by(
workflow_id=workflow_id
).order_by(Step.order).all()
if not steps_db:
return jsonify({
'success': False,
'error': "Le workflow n'a aucune étape"
}), 400
steps_data = [s.to_dict() for s in steps_db]
# Récupérer les edges depuis le body (le frontend les envoie)
data = request.get_json() or {}
edges_data = data.get("edges", [])
# Si pas d'edges fournis, créer une chaîne linéaire par défaut
if not edges_data:
for i in range(len(steps_data) - 1):
edges_data.append({
"source": steps_data[i]["id"],
"target": steps_data[i + 1]["id"],
})
# Paramètres optionnels
timeout = data.get("timeout", 300)
model = data.get("model", "qwen3-vl:8b")
ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434")
# Convertir en étapes DAG
dag_steps = _convert_vwb_to_dag_steps(steps_data, edges_data)
# Vérifier s'il y a des étapes LLM
has_llm_steps = any(s.step_type == StepType.LLM_CALL for s in dag_steps)
# Créer le handler LLM si nécessaire
llm_handler = None
if has_llm_steps:
llm_handler = LLMActionHandler(
ollama_endpoint=ollama_endpoint,
model=model,
)
# Créer et configurer l'exécuteur
executor = DAGExecutor(
max_llm_workers=2,
max_ui_workers=1,
llm_handler=llm_handler,
)
# Charger le workflow dans le DAG
executor.load_workflow(dag_steps)
# Garder une référence pour le status
_current_executor = executor
_last_result = None
logger.info(
"Lancement exécution DAG pour workflow '%s' : %d étapes (%d LLM)",
workflow_id,
len(dag_steps),
sum(1 for s in dag_steps if s.step_type == StepType.LLM_CALL),
)
# Exécuter (bloquant — le timeout protège)
result = executor.execute(timeout=timeout)
_last_result = result
logger.info(
"Exécution DAG terminée : success=%s, durée=%.2fs",
result.success,
result.duration_seconds,
)
return jsonify({
'success': True,
'execution': result.to_dict(),
})
except ValueError as e:
return jsonify({
'success': False,
'error': f"Erreur de validation : {str(e)}"
}), 400
except Exception as e:
traceback.print_exc()
return jsonify({
'success': False,
'error': f"Erreur d'exécution : {str(e)}"
}), 500
@api_v3_bp.route('/workflow/<workflow_id>/dag-status', methods=['GET'])
def get_dag_status(workflow_id: str):
"""
Retourne le statut de la dernière exécution DAG.
Response :
{
"success": true,
"status": { "steps": {...}, "results": {...}, "summary": {...} }
}
"""
global _current_executor, _last_result
try:
# Si une exécution est terminée, retourner le résultat
if _last_result is not None:
return jsonify({
'success': True,
'completed': True,
'status': _last_result.to_dict(),
})
# Si un exécuteur est en cours, retourner son état
if _current_executor is not None:
return jsonify({
'success': True,
'completed': False,
'status': _current_executor.get_status(),
})
return jsonify({
'success': True,
'completed': False,
'status': None,
'message': "Aucune exécution DAG en cours",
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -314,6 +314,35 @@ VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
optional_params=["match_mode", "case_sensitive"],
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
),
# --- ACTIONS DAG LLM — Exécutées via le DAGExecutor ---
"llm_analyze": ActionContract(
action_type="llm_analyze",
description="Analyser/résumer un texte via LLM (DAGExecutor)",
required_params=[],
optional_params=["text", "instruction", "model", "temperature"],
),
"llm_translate": ActionContract(
action_type="llm_translate",
description="Traduire un texte via LLM (DAGExecutor)",
required_params=[],
optional_params=["text", "target_lang", "source_lang", "model", "temperature"],
),
"llm_extract_data": ActionContract(
action_type="llm_extract_data",
description="Extraire des données structurées d'un texte via LLM (DAGExecutor)",
required_params=[],
optional_params=["text", "schema", "model", "temperature"],
),
"llm_generate": ActionContract(
action_type="llm_generate",
description="Générer du texte via LLM (DAGExecutor)",
required_params=[],
optional_params=["prompt", "context", "model", "temperature"],
),
}

View File

@@ -788,6 +788,194 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
// === DAG LLM ===
case 'llm_analyze':
return (
<>
<div className="prop-section-title">
<span className="icon">🔬</span> Analyser texte (LLM)
</div>
<div className="prop-field">
<label>Texte à analyser</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Instruction</label>
<textarea
value={String(params.instruction || 'Analyse et résume ce texte.')}
onChange={(e) => updateParam('instruction', e.target.value)}
rows={2}
placeholder="Analyse et résume ce texte."
/>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.1).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.1)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
</div>
</>
);
case 'llm_translate':
return (
<>
<div className="prop-section-title">
<span className="icon">🌐</span> Traduire (LLM)
</div>
<div className="prop-field">
<label>Texte à traduire</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Langue cible</label>
<select
value={String(params.target_lang || 'français')}
onChange={(e) => updateParam('target_lang', e.target.value)}
>
<option value="français">Français</option>
<option value="anglais">Anglais</option>
<option value="espagnol">Espagnol</option>
<option value="allemand">Allemand</option>
<option value="chinois">Chinois</option>
<option value="arabe">Arabe</option>
<option value="japonais">Japonais</option>
</select>
</div>
<div className="prop-field">
<label>Langue source (optionnel)</label>
<input
type="text"
value={String(params.source_lang || '')}
onChange={(e) => updateParam('source_lang', e.target.value)}
placeholder="Auto-détection"
/>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
</>
);
case 'llm_extract_data':
return (
<>
<div className="prop-section-title">
<span className="icon">🗂</span> Extraire données (LLM)
</div>
<div className="prop-field">
<label>Texte source</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Schéma d'extraction (JSON)</label>
<textarea
value={String(params.schema || '{\n "nom": "Nom complet",\n "date": "Date au format JJ/MM/AAAA"\n}')}
onChange={(e) => updateParam('schema', e.target.value)}
rows={5}
placeholder={'{\n "nom": "Nom complet",\n "date": "Date"\n}'}
/>
<small className="field-hint">Clés = champs à extraire, valeurs = descriptions</small>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
</>
);
case 'llm_generate':
return (
<>
<div className="prop-section-title">
<span className="icon"></span> Générer texte (LLM)
</div>
<div className="prop-field">
<label>Prompt de génération</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={4}
placeholder="Rédige un email de relance à partir des données suivantes..."
/>
</div>
<div className="prop-field">
<label>Contexte (optionnel)</label>
<textarea
value={String(params.context || '')}
onChange={(e) => updateParam('context', e.target.value)}
rows={3}
placeholder={"Contexte additionnel ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.1).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.1)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
<small className="field-hint">0 = précis/déterministe, 1 = créatif/varié</small>
</div>
</>
);
// === BDD ===
case 'db_save_data':
return (

View File

@@ -45,6 +45,23 @@ function StepNode({ data, selected }: StepNodeProps) {
</div>
)}
{/* Aperçu des paramètres LLM DAG */}
{step.action_type === 'llm_translate' && step.parameters?.target_lang && (
<div className="step-node-params">
{`${step.parameters.target_lang}`}
</div>
)}
{step.action_type === 'llm_generate' && typeof step.parameters?.prompt === 'string' && step.parameters.prompt.length > 0 && (
<div className="step-node-params">
{`"${step.parameters.prompt.slice(0, 25)}${step.parameters.prompt.length > 25 ? '...' : ''}"`}
</div>
)}
{step.action_type === 'llm_analyze' && typeof step.parameters?.instruction === 'string' && step.parameters.instruction.length > 0 && (
<div className="step-node-params">
{`"${step.parameters.instruction.slice(0, 25)}${step.parameters.instruction.length > 25 ? '...' : ''}"`}
</div>
)}
{!step.anchor_id && action?.needsAnchor && (
<div className="step-node-warning">
Ancre requise

View File

@@ -260,3 +260,49 @@ export async function submitReview(
}> {
return request('POST', `/workflow/${workflowId}/review`, { status, feedback });
}
// DAG Execution — Exécution parallèle avec étapes LLM
export interface DAGEdge {
source: string;
target: string;
}
export interface DAGExecutionResult {
success: boolean;
steps: Record<string, {
step_id: string;
step_type: string;
status: string;
result: unknown;
error: string | null;
duration: number | null;
}>;
results: Record<string, unknown>;
errors: string[];
duration_seconds: number;
}
export async function executeDag(
workflowId: string,
edges: DAGEdge[],
options?: {
timeout?: number;
model?: string;
ollama_endpoint?: string;
}
): Promise<{ execution: DAGExecutionResult }> {
return request('POST', `/workflow/${workflowId}/execute-dag`, {
edges,
...options,
});
}
export async function getDagStatus(
workflowId: string
): Promise<{
completed: boolean;
status: DAGExecutionResult | null;
message?: string;
}> {
return request('GET', `/workflow/${workflowId}/dag-status`);
}

View File

@@ -48,13 +48,18 @@ export type ActionType =
| 'db_save_data'
| 'db_read_data'
| 'verify_element_exists'
| 'verify_text_content';
| 'verify_text_content'
// === DAG LLM — étapes IA exécutées via le DAGExecutor ===
| 'llm_analyze'
| 'llm_translate'
| 'llm_extract_data'
| 'llm_generate';
export interface ActionDefinition {
type: ActionType;
label: string;
icon: string;
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'validation';
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation';
needsAnchor: boolean;
params: string[];
}
@@ -99,6 +104,12 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },
{ type: 'db_read_data', label: 'Lire depuis BDD', icon: '📖', category: 'data', needsAnchor: false, params: ['query', 'variable_name'] },
// === DAG LLM — Actions IA via DAGExecutor (parallèle, Ollama) ===
{ type: 'llm_analyze', label: 'Analyser texte', icon: '🔬', category: 'llm', needsAnchor: false, params: ['text', 'instruction', 'model'] },
{ type: 'llm_translate', label: 'Traduire', icon: '🌐', category: 'llm', needsAnchor: false, params: ['text', 'target_lang', 'model'] },
{ type: 'llm_extract_data', label: 'Extraire données', icon: '🗂️', category: 'llm', needsAnchor: false, params: ['text', 'schema', 'model'] },
{ type: 'llm_generate', label: 'Générer texte', icon: '✍️', category: 'llm', needsAnchor: false, params: ['prompt', 'context', 'model'] },
// === VALIDATION ===
{ type: 'verify_element_exists', label: 'Vérifier présence', icon: '✅', category: 'validation', needsAnchor: true, params: ['timeout_ms'] },
{ type: 'verify_text_content', label: 'Vérifier texte', icon: '🔍', category: 'validation', needsAnchor: true, params: ['expected_text'] },
@@ -111,6 +122,7 @@ export const ACTION_CATEGORIES = {
data: { label: 'Données', icon: '📊' },
logic: { label: 'Logique', icon: '🔀' },
ai: { label: 'IA', icon: '🤖' },
llm: { label: 'IA / LLM', icon: '🧪' },
validation: { label: 'Validation', icon: '✅' },
};