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