feat: WorkflowRunner, matching sémantique et replay distant (P0-4, P0-6, P0-7)

P0-4: WorkflowRunner — orchestrateur de replay intelligent
- Boucle capture → match FAISS → résolution sémantique → exécution
- Mode dry_run, substitution de variables, anti-boucle (max 200 steps)
- Découplé de pyautogui via executor_callback

P0-6: Unification des répertoires workflows
- SemanticMatcher scanne data/workflows/ + data/training/workflows/
- Auto-reload sur changement de répertoire (60s)

P0-7: Matching sémantique via Ollama
- Pré-filtrage Jaccard + re-ranking LLM (qwen2.5:7b)
- Score final : 40% Jaccard + 60% LLM, fallback si Ollama indisponible

Agent Chat: exécution distante via streaming server
- POST http://localhost:5005/api/v1/traces/stream/replay
- Fallback sur exécution locale si serveur indisponible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-14 11:23:33 +01:00
parent de779af5a1
commit 148321dffd
4 changed files with 1615 additions and 144 deletions

View File

@@ -4,18 +4,36 @@ Semantic Matcher - Matching sémantique des commandes en langage naturel
Permet de :
- Trouver le workflow correspondant à une commande en langage naturel
- Extraire les paramètres de la commande
- Utiliser des embeddings pour le matching sémantique
- Matching multi-répertoires (workflows manuels + appris par streaming)
- Matching sémantique via LLM (Ollama) avec fallback Jaccard
P0-6 : Unification des répertoires workflows
P0-7 : Matching sémantique via Ollama
"""
import re
import logging
from typing import Dict, Any, List, Optional, Tuple
import time
import threading
from typing import Dict, Any, List, Optional, Tuple, Union
from dataclasses import dataclass
from pathlib import Path
import json
logger = logging.getLogger(__name__)
# Répertoires par défaut à scanner pour les workflows
DEFAULT_WORKFLOW_DIRS = [
"data/workflows", # Workflows manuels / existants
"data/training/workflows", # Workflows appris par le StreamProcessor (défaut)
"data/training/live_sessions/workflows", # Workflows appris via api_stream (live_sessions data_dir)
]
# Configuration Ollama par défaut
DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen2.5:7b"
DEFAULT_LLM_TIMEOUT = 10 # secondes
@dataclass
class WorkflowMatch:
@@ -38,117 +56,261 @@ class WorkflowMetadata:
keywords: List[str]
param_patterns: List[str] # Patterns pour extraire les paramètres
path: str
source_dir: str = "" # Répertoire source (pour debug/traçabilité)
class SemanticMatcher:
"""
Matcher sémantique pour trouver des workflows depuis des commandes.
Utilise plusieurs stratégies :
1. Matching exact par nom/tags
2. Matching par mots-clés
3. Matching par embeddings (si disponible)
Utilise plusieurs stratégies en cascade :
1. Matching exact par nom/tags (rapide)
2. Matching par mots-clés Jaccard (rapide)
3. Matching sémantique via LLM Ollama (top-5 candidats, précis)
4. Extraction de paramètres
Supporte le scan multi-répertoires pour unifier :
- data/workflows/ (workflows manuels)
- data/training/workflows/ (workflows appris par streaming)
- data/training/live_sessions/workflows/ (workflows live_sessions)
Auto-reload : détecte les nouveaux workflows via mtime (toutes les 60s).
Example:
>>> matcher = SemanticMatcher("data/workflows")
>>> matcher = SemanticMatcher()
>>> result = matcher.find_workflow("facturer le client Acme")
>>> print(result.workflow_name) # "Facturation Client"
>>> print(result.extracted_params) # {"client": "Acme"}
"""
def __init__(
self,
workflows_dir: str = "data/workflows",
use_embeddings: bool = True
workflows_dir: Union[str, List[str], None] = None,
use_embeddings: bool = True,
use_llm: bool = True,
llm_model: str = DEFAULT_OLLAMA_MODEL,
llm_endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
llm_timeout: int = DEFAULT_LLM_TIMEOUT,
auto_reload_interval: int = 60,
):
"""
Initialiser le matcher.
Args:
workflows_dir: Répertoire des workflows
use_embeddings: Utiliser les embeddings pour le matching
workflows_dir: Répertoire(s) des workflows. Si None, utilise DEFAULT_WORKFLOW_DIRS.
Peut être un str (un seul répertoire) ou une liste.
use_embeddings: Utiliser les embeddings pour le matching (compatibilité)
use_llm: Activer le matching sémantique via Ollama LLM
llm_model: Modèle Ollama à utiliser (défaut: qwen2.5:7b)
llm_endpoint: Endpoint Ollama (défaut: http://localhost:11434)
llm_timeout: Timeout pour les appels LLM en secondes
auto_reload_interval: Intervalle en secondes pour vérifier les nouveaux workflows (0 = désactivé)
"""
self.workflows_dir = Path(workflows_dir)
# Gérer la rétro-compatibilité : un seul str → liste
if workflows_dir is None:
self._workflows_dirs = [Path(d) for d in DEFAULT_WORKFLOW_DIRS]
elif isinstance(workflows_dir, str):
self._workflows_dirs = [Path(workflows_dir)]
elif isinstance(workflows_dir, (list, tuple)):
self._workflows_dirs = [Path(d) for d in workflows_dir]
else:
self._workflows_dirs = [Path(workflows_dir)]
# Garder l'attribut simple pour la rétro-compatibilité
self.workflows_dir = self._workflows_dirs[0] if self._workflows_dirs else Path("data/workflows")
self.use_embeddings = use_embeddings
self.use_llm = use_llm
self.llm_model = llm_model
self.llm_endpoint = llm_endpoint
self.llm_timeout = llm_timeout
# Cache des métadonnées
self._workflows: Dict[str, WorkflowMetadata] = {}
# Embedder (chargé à la demande)
# Embedder (compatibilité, non utilisé pour le matching LLM)
self._embedder = None
self._workflow_embeddings: Dict[str, Any] = {}
# Charger les workflows
# Auto-reload : timestamps des répertoires pour détecter les changements
self._dir_mtimes: Dict[str, float] = {}
self._auto_reload_interval = auto_reload_interval
self._last_reload_check = 0.0
self._reload_lock = threading.Lock()
# État LLM
self._llm_available: Optional[bool] = None # None = pas encore testé
# Charger les workflows au démarrage
self._load_workflows()
# =========================================================================
# Chargement multi-répertoires
# =========================================================================
def _load_workflows(self) -> None:
"""Charger les métadonnées de tous les workflows."""
if not self.workflows_dir.exists():
logger.warning(f"Workflows directory not found: {self.workflows_dir}")
return
for workflow_path in self.workflows_dir.glob("*.json"):
"""Charger les métadonnées de tous les workflows depuis tous les répertoires."""
self._workflows.clear()
total_loaded = 0
for workflows_dir in self._workflows_dirs:
if not workflows_dir.exists():
logger.debug(f"Répertoire workflows absent (ignoré): {workflows_dir}")
continue
count = self._load_workflows_from_dir(workflows_dir)
total_loaded += count
# Mémoriser le mtime pour l'auto-reload
try:
self._dir_mtimes[str(workflows_dir)] = workflows_dir.stat().st_mtime
except OSError:
pass
self._last_reload_check = time.time()
logger.info(
f"SemanticMatcher: {total_loaded} workflow(s) chargé(s) "
f"depuis {len(self._workflows_dirs)} répertoire(s)"
)
def _load_workflows_from_dir(self, workflows_dir: Path) -> int:
"""
Charger les workflows d'un répertoire spécifique.
Returns:
Nombre de workflows chargés
"""
count = 0
for workflow_path in workflows_dir.glob("*.json"):
try:
with open(workflow_path, 'r', encoding='utf-8') as f:
data = json.load(f)
workflow_id = workflow_path.stem
# Éviter les doublons (le premier répertoire a la priorité)
if workflow_id in self._workflows:
logger.debug(
f"Workflow {workflow_id} déjà chargé, "
f"ignoré depuis {workflows_dir}"
)
continue
# Extraire le nom — compatibilité entre les deux formats de workflow
name = data.get("name", workflow_id)
description = data.get("description", "")
# Tags : supportent les deux formats
tags = data.get("tags", [])
# Format alternatif : tags dans metadata
if not tags and "metadata" in data:
tags = data["metadata"].get("tags", [])
# Extraire les métadonnées
metadata = WorkflowMetadata(
workflow_id=workflow_id,
name=data.get("name", workflow_id),
description=data.get("description", ""),
tags=data.get("tags", []),
name=name,
description=description,
tags=tags,
keywords=self._extract_keywords(data),
param_patterns=data.get("param_patterns", []),
path=str(workflow_path)
path=str(workflow_path),
source_dir=str(workflows_dir),
)
self._workflows[workflow_id] = metadata
logger.debug(f"Loaded workflow: {metadata.name}")
count += 1
logger.debug(f"Workflow chargé: {metadata.name} [{workflows_dir.name}]")
except Exception as e:
logger.error(f"Error loading workflow {workflow_path}: {e}")
logger.info(f"Loaded {len(self._workflows)} workflows")
logger.error(f"Erreur chargement workflow {workflow_path}: {e}")
if count:
logger.info(f" {count} workflow(s) depuis {workflows_dir}")
return count
def _check_auto_reload(self) -> None:
"""
Vérifier si les répertoires ont changé et recharger si nécessaire.
Appelé avant chaque recherche, vérifie le mtime des répertoires
au maximum toutes les `auto_reload_interval` secondes.
"""
if self._auto_reload_interval <= 0:
return
now = time.time()
if now - self._last_reload_check < self._auto_reload_interval:
return
with self._reload_lock:
# Double-check après acquisition du lock
if time.time() - self._last_reload_check < self._auto_reload_interval:
return
needs_reload = False
for workflows_dir in self._workflows_dirs:
if not workflows_dir.exists():
# Répertoire créé entre-temps ?
if str(workflows_dir) not in self._dir_mtimes:
needs_reload = True
break
continue
try:
current_mtime = workflows_dir.stat().st_mtime
prev_mtime = self._dir_mtimes.get(str(workflows_dir), 0)
if current_mtime > prev_mtime:
needs_reload = True
break
except OSError:
pass
self._last_reload_check = time.time()
if needs_reload:
logger.info("Changements détectés dans les répertoires workflows, rechargement...")
self._load_workflows()
def _extract_keywords(self, workflow_data: Dict[str, Any]) -> List[str]:
"""Extraire les mots-clés d'un workflow."""
keywords = set()
# Nom
name = workflow_data.get("name", "")
keywords.update(self._tokenize(name))
# Description
description = workflow_data.get("description", "")
keywords.update(self._tokenize(description))
# Tags
keywords.update(workflow_data.get("tags", []))
tags = workflow_data.get("tags", [])
if not tags and "metadata" in workflow_data:
tags = workflow_data["metadata"].get("tags", [])
keywords.update(tags)
# Actions (noms des actions)
for edge in workflow_data.get("edges", []):
action = edge.get("action", {})
if isinstance(action, dict):
action_type = action.get("type", "")
keywords.add(action_type)
return list(keywords)
def _tokenize(self, text: str) -> List[str]:
"""Tokeniser un texte en mots-clés."""
# Normaliser
text = text.lower()
# Supprimer la ponctuation
text = re.sub(r'[^\w\s]', ' ', text)
# Découper en mots
words = text.split()
# Filtrer les mots courts et les stop words
stop_words = {
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'à', 'au', 'aux',
@@ -156,13 +318,222 @@ class SemanticMatcher:
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been'
}
return [w for w in words if len(w) > 2 and w not in stop_words]
# =========================================================================
# Matching LLM (Ollama)
# =========================================================================
def _check_llm_availability(self) -> bool:
"""Vérifier si Ollama est disponible avec le modèle configuré."""
try:
import requests
resp = requests.get(
f"{self.llm_endpoint}/api/tags",
timeout=3
)
if resp.status_code == 200:
models = resp.json().get("models", [])
model_names = [m.get("name", "") for m in models]
# Vérifier que le modèle est disponible (avec ou sans tag)
available = any(
self.llm_model in name or name in self.llm_model
for name in model_names
)
self._llm_available = available
if not available:
logger.warning(
f"Modèle {self.llm_model} non trouvé dans Ollama "
f"(disponibles: {model_names[:5]})"
)
return available
except Exception as e:
logger.debug(f"Ollama indisponible: {e}")
self._llm_available = False
return False
def _llm_semantic_rerank(
self,
command: str,
candidates: List[WorkflowMatch],
) -> List[WorkflowMatch]:
"""
Re-classer les candidats via le LLM Ollama pour un matching sémantique.
Envoie la commande utilisateur et la liste des workflows candidats au LLM,
lui demande de scorer la pertinence de chaque workflow (0-100).
Args:
command: Commande en langage naturel de l'utilisateur
candidates: Liste de WorkflowMatch pré-filtrés par Jaccard (top-5)
Returns:
Liste re-classée par score sémantique LLM
"""
if not candidates:
return candidates
# Vérifier la disponibilité du LLM (cache le résultat)
if self._llm_available is None:
self._check_llm_availability()
if not self._llm_available:
logger.debug("LLM indisponible, utilisation du score Jaccard seul")
return candidates
# Construire la liste des workflows pour le prompt
workflows_desc = []
for i, match in enumerate(candidates):
meta = self._workflows.get(match.workflow_id)
desc = meta.description if meta else ""
tags = ", ".join(meta.tags) if meta and meta.tags else "aucun"
workflows_desc.append(
f"{i+1}. ID: {match.workflow_id} | "
f"Nom: {match.workflow_name} | "
f"Description: {desc or 'aucune'} | "
f"Tags: {tags}"
)
workflows_list = "\n".join(workflows_desc)
prompt = f"""Tu es un assistant RPA. L'utilisateur demande une action en langage naturel.
Tu dois évaluer la pertinence de chaque workflow candidat par rapport à sa demande.
DEMANDE UTILISATEUR: "{command}"
WORKFLOWS CANDIDATS:
{workflows_list}
Pour chaque workflow, donne un score de pertinence de 0 à 100.
- 100 = correspondance parfaite (même action, même domaine)
- 70-99 = très pertinent (action similaire ou liée)
- 30-69 = moyennement pertinent
- 0-29 = pas pertinent
Réponds UNIQUEMENT au format JSON, sans texte avant ni après:
{{"scores": [{{"id": "workflow_id", "score": 85, "raison": "explication courte"}}]}}"""
try:
import requests
resp = requests.post(
f"{self.llm_endpoint}/api/generate",
json={
"model": self.llm_model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.1, # Déterministe
"num_predict": 500, # Réponse courte
},
},
timeout=self.llm_timeout,
)
if resp.status_code != 200:
logger.warning(f"Ollama erreur HTTP {resp.status_code}")
return candidates
response_text = resp.json().get("response", "")
# Parser la réponse JSON du LLM
scores = self._parse_llm_scores(response_text, candidates)
if scores:
# Appliquer les scores LLM aux candidats
reranked = []
for match in candidates:
llm_score = scores.get(match.workflow_id, 0) / 100.0
# Score final : pondération 40% Jaccard + 60% LLM
combined_score = 0.4 * match.confidence + 0.6 * llm_score
reranked.append(WorkflowMatch(
workflow_id=match.workflow_id,
workflow_name=match.workflow_name,
workflow_path=match.workflow_path,
confidence=min(combined_score, 1.0),
extracted_params=match.extracted_params,
match_reason=match.match_reason + f" | llm_score:{int(llm_score*100)}",
))
# Re-trier par score combiné
reranked.sort(key=lambda m: m.confidence, reverse=True)
logger.info(
f"LLM reranking: {len(reranked)} candidats re-classés "
f"(meilleur: {reranked[0].workflow_name} @ {reranked[0].confidence:.2f})"
)
return reranked
except Exception as e:
logger.warning(f"Erreur matching LLM (fallback Jaccard): {e}")
# Marquer comme indisponible temporairement
self._llm_available = None
return candidates
def _parse_llm_scores(
self,
response_text: str,
candidates: List[WorkflowMatch],
) -> Dict[str, float]:
"""
Parser les scores retournés par le LLM.
Gère les différents formats possibles de réponse JSON.
Returns:
Dict workflow_id → score (0-100)
"""
scores = {}
try:
# Essayer de trouver le JSON dans la réponse
# Le LLM peut ajouter du texte avant/après le JSON
json_match = re.search(r'\{[\s\S]*"scores"[\s\S]*\}', response_text)
if not json_match:
logger.debug(f"Pas de JSON trouvé dans la réponse LLM: {response_text[:200]}")
return scores
data = json.loads(json_match.group())
score_list = data.get("scores", [])
# Mapper les scores aux workflow_ids
candidate_ids = {m.workflow_id for m in candidates}
for entry in score_list:
wf_id = entry.get("id", "")
score = entry.get("score", 0)
# Normaliser le score
if isinstance(score, (int, float)):
score = max(0, min(100, score))
else:
continue
# Vérifier que l'ID correspond à un candidat
if wf_id in candidate_ids:
scores[wf_id] = score
else:
# Le LLM a peut-être utilisé un index au lieu de l'ID
# Essayer de résoudre par position
try:
idx = int(wf_id) - 1
if 0 <= idx < len(candidates):
scores[candidates[idx].workflow_id] = score
except (ValueError, IndexError):
pass
except json.JSONDecodeError as e:
logger.debug(f"Erreur parsing JSON LLM: {e}")
except Exception as e:
logger.debug(f"Erreur parsing scores LLM: {e}")
return scores
# =========================================================================
# Matching
# =========================================================================
def find_workflow(
self,
command: str,
@@ -170,17 +541,17 @@ class SemanticMatcher:
) -> Optional[WorkflowMatch]:
"""
Trouver le workflow correspondant à une commande.
Args:
command: Commande en langage naturel
min_confidence: Confiance minimale requise
Returns:
WorkflowMatch ou None si aucun match
"""
matches = self.find_workflows(command, limit=1, min_confidence=min_confidence)
return matches[0] if matches else None
def find_workflows(
self,
command: str,
@@ -189,45 +560,63 @@ class SemanticMatcher:
) -> List[WorkflowMatch]:
"""
Trouver les workflows correspondant à une commande.
Stratégie en 2 phases :
1. Pré-filtrage rapide par Jaccard/tags/nom → top-5 candidats
2. Re-ranking sémantique via LLM Ollama (si activé et disponible)
Args:
command: Commande en langage naturel
limit: Nombre max de résultats
min_confidence: Confiance minimale requise
Returns:
Liste de WorkflowMatch triés par confiance
"""
# Auto-reload si des changements sont détectés
self._check_auto_reload()
if not self._workflows:
logger.warning("No workflows loaded")
logger.warning("Aucun workflow chargé")
return []
command_lower = command.lower()
command_tokens = set(self._tokenize(command))
matches = []
# Phase 1 : Pré-filtrage Jaccard (rapide)
jaccard_matches = []
for workflow_id, metadata in self._workflows.items():
# Calculer le score de matching
# Calculer le score de matching Jaccard
score, reason, params = self._calculate_match_score(
command_lower, command_tokens, metadata
)
if score >= min_confidence:
matches.append(WorkflowMatch(
if score > 0: # Garder tous les candidats avec un score > 0
jaccard_matches.append(WorkflowMatch(
workflow_id=workflow_id,
workflow_name=metadata.name,
workflow_path=metadata.path,
confidence=score,
extracted_params=params,
match_reason=reason
match_reason=reason,
))
# Trier par confiance décroissante
matches.sort(key=lambda m: m.confidence, reverse=True)
return matches[:limit]
jaccard_matches.sort(key=lambda m: m.confidence, reverse=True)
# Prendre les top-5 pour le re-ranking LLM
top_candidates = jaccard_matches[:5]
# Phase 2 : Re-ranking sémantique via LLM (si activé)
if self.use_llm and top_candidates:
top_candidates = self._llm_semantic_rerank(command, top_candidates)
# Filtrer par confiance minimale
filtered = [m for m in top_candidates if m.confidence >= min_confidence]
return filtered[:limit]
def _calculate_match_score(
self,
command: str,
@@ -236,25 +625,25 @@ class SemanticMatcher:
) -> Tuple[float, str, Dict[str, str]]:
"""
Calculer le score de matching entre une commande et un workflow.
Returns:
(score, reason, extracted_params)
"""
score = 0.0
reasons = []
params = {}
# 1. Matching exact du nom
if metadata.name.lower() in command:
score += 0.5
reasons.append("exact_name")
# 2. Matching des tags
for tag in metadata.tags:
if tag.lower() in command:
score += 0.3
reasons.append(f"tag:{tag}")
# 3. Matching des mots-clés (Jaccard similarity)
workflow_tokens = set(metadata.keywords)
if workflow_tokens and command_tokens:
@@ -264,7 +653,7 @@ class SemanticMatcher:
score += jaccard * 0.4
if intersection:
reasons.append(f"keywords:{','.join(intersection)}")
# 4. Matching de la description
if metadata.description:
desc_tokens = set(self._tokenize(metadata.description))
@@ -273,18 +662,18 @@ class SemanticMatcher:
if intersection:
score += 0.2
reasons.append("description_match")
# 5. Extraction des paramètres
params = self._extract_params(command, metadata)
if params:
score += 0.1
reasons.append(f"params:{','.join(params.keys())}")
# Normaliser le score (max 1.0)
score = min(score, 1.0)
return score, " | ".join(reasons), params
def _extract_params(
self,
command: str,
@@ -292,11 +681,11 @@ class SemanticMatcher:
) -> Dict[str, str]:
"""
Extraire les paramètres d'une commande.
Utilise les patterns définis dans le workflow et des heuristiques.
"""
params = {}
# 1. Utiliser les patterns définis
for pattern in metadata.param_patterns:
try:
@@ -304,8 +693,8 @@ class SemanticMatcher:
if match:
params.update(match.groupdict())
except Exception as e:
logger.warning(f"Invalid pattern '{pattern}': {e}")
logger.warning(f"Pattern invalide '{pattern}': {e}")
# 2. Heuristiques communes
# Pattern: "de X à Y" ou "from X to Y"
range_pattern = r'(?:de|from)\s+(\w+)\s+(?:à|to)\s+(\w+)'
@@ -313,38 +702,46 @@ class SemanticMatcher:
if match:
params['start'] = match.group(1)
params['end'] = match.group(2)
# Pattern: "client X" ou "customer X"
client_pattern = r'(?:client|customer|compte)\s+([A-Za-z0-9_\-]+)'
match = re.search(client_pattern, command, re.IGNORECASE)
if match:
params['client'] = match.group(1)
# Pattern: "facture N" ou "invoice N"
invoice_pattern = r'(?:facture|invoice|commande|order)\s+([A-Za-z0-9_\-]+)'
match = re.search(invoice_pattern, command, re.IGNORECASE)
if match:
params['invoice'] = match.group(1)
# Pattern: valeurs entre guillemets
quoted_pattern = r'"([^"]+)"'
quoted_values = re.findall(quoted_pattern, command)
for i, value in enumerate(quoted_values):
if f'value{i}' not in params:
params[f'value{i}'] = value
return params
# =========================================================================
# Gestion des workflows
# =========================================================================
def reload_workflows(self) -> None:
"""Recharger tous les workflows."""
self._workflows.clear()
self._workflow_embeddings.clear()
self._load_workflows()
def reload_workflows(self) -> int:
"""
Recharger tous les workflows depuis tous les répertoires.
Returns:
Nombre total de workflows chargés
"""
with self._reload_lock:
self._workflows.clear()
self._workflow_embeddings.clear()
self._llm_available = None # Re-tester la dispo LLM
self._load_workflows()
return len(self._workflows)
def add_workflow(
self,
workflow_id: str,
@@ -356,7 +753,7 @@ class SemanticMatcher:
) -> None:
"""
Ajouter un workflow au matcher.
Args:
workflow_id: ID unique du workflow
name: Nom du workflow
@@ -372,77 +769,101 @@ class SemanticMatcher:
tags=tags or [],
keywords=self._tokenize(name) + self._tokenize(description) + (tags or []),
param_patterns=param_patterns or [],
path=path
path=path,
source_dir="dynamic",
)
self._workflows[workflow_id] = metadata
logger.info(f"Added workflow: {name}")
logger.info(f"Workflow ajouté: {name}")
def get_all_workflows(self) -> List[WorkflowMetadata]:
"""Obtenir tous les workflows."""
return list(self._workflows.values())
def get_workflow(self, workflow_id: str) -> Optional[WorkflowMetadata]:
"""Obtenir un workflow par ID."""
return self._workflows.get(workflow_id)
def get_directories(self) -> List[Dict[str, Any]]:
"""
Obtenir la liste des répertoires scannés et leur contenu.
Returns:
Liste de dicts avec path, exists, workflow_count
"""
dirs_info = []
for d in self._workflows_dirs:
count = sum(
1 for wf in self._workflows.values()
if wf.source_dir == str(d)
)
dirs_info.append({
"path": str(d),
"exists": d.exists(),
"workflow_count": count,
})
return dirs_info
# =========================================================================
# Suggestions
# =========================================================================
def suggest_commands(self, partial_command: str, limit: int = 5) -> List[str]:
"""
Suggérer des commandes basées sur une entrée partielle.
Args:
partial_command: Début de commande
limit: Nombre max de suggestions
Returns:
Liste de suggestions
"""
suggestions = []
partial_lower = partial_command.lower()
for metadata in self._workflows.values():
# Suggérer basé sur le nom
if metadata.name.lower().startswith(partial_lower):
suggestions.append(metadata.name)
# Suggérer basé sur les tags
for tag in metadata.tags:
if tag.lower().startswith(partial_lower):
suggestions.append(f"{tag} ({metadata.name})")
return suggestions[:limit]
def get_workflow_help(self, workflow_id: str) -> str:
"""
Obtenir l'aide pour un workflow.
Args:
workflow_id: ID du workflow
Returns:
Texte d'aide
"""
metadata = self._workflows.get(workflow_id)
if not metadata:
return f"Workflow '{workflow_id}' not found"
help_text = f"📋 {metadata.name}\n"
return f"Workflow '{workflow_id}' non trouvé"
help_text = f"Workflow: {metadata.name}\n"
if metadata.description:
help_text += f"\n{metadata.description}\n"
if metadata.tags:
help_text += f"\n🏷️ Tags: {', '.join(metadata.tags)}\n"
help_text += f"\nTags: {', '.join(metadata.tags)}\n"
if metadata.param_patterns:
help_text += f"\n📝 Paramètres supportés:\n"
help_text += f"\nParametres supportes:\n"
for pattern in metadata.param_patterns:
help_text += f" - {pattern}\n"
if metadata.source_dir:
help_text += f"\nSource: {metadata.source_dir}\n"
return help_text
@@ -450,14 +871,18 @@ class SemanticMatcher:
# Fonctions utilitaires
# =============================================================================
def create_semantic_matcher(workflows_dir: str = "data/workflows") -> SemanticMatcher:
def create_semantic_matcher(
workflows_dir: Union[str, List[str], None] = None,
use_llm: bool = True,
) -> SemanticMatcher:
"""
Créer un matcher sémantique.
Args:
workflows_dir: Répertoire des workflows
workflows_dir: Répertoire(s) des workflows. None = tous les répertoires par défaut.
use_llm: Activer le matching LLM via Ollama
Returns:
SemanticMatcher configuré
"""
return SemanticMatcher(workflows_dir=workflows_dir)
return SemanticMatcher(workflows_dir=workflows_dir, use_llm=use_llm)