Files
rpa_vision_v3/docs/handoffs/2026-05-11_dag_execute_avant_expand.py
Dom 5ea4960e65
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
backup: snapshot post-démo GHT 2026-05-19
Backup état complet après enregistrement vidéo démo de bout en bout.
À utiliser comme point de référence pour la consolidation post-démo.

Changements majeurs de la session 18-19 mai :
- AIVA-URGENCE : page autonome avec preset URL + auto-focus chain
- Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine
- Bypass LLM (static_result / static_text) dans replay_engine
  pour démos déterministes sans appel Ollama
- Fix api_stream:3013 — replay_paused au premier polling /next
- dag_execute : lift duration_ms vers top-level pour wait runtime
- NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git)
- scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue

Anchors visuels (468) forcés dans le commit pour garantir restorabilité.
DB workflows actuelle + ~12 .bak DB de la journée incluses.

Sujets identifiés pour consolidation post-démo (TODO) :
1. Bug VWB recapture anchor ne régénère pas le PNG
2. Léa client accumule état mémoire (restart périodique requis)
3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel)
4. Bug coord client mss tronqué 2560x60 → mapping Y cassé
5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:55:06 +02:00

1211 lines
44 KiB
Python

"""
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 os
import re
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
# Data loop — import Excel et itération sur tables
try:
from core.data.excel_importer import ExcelImporter
from core.data.db_iterator import DBIterator
_DATA_LOOP_AVAILABLE = True
except ImportError as _e:
logger.warning("Module core.data indisponible : %s", _e)
_DATA_LOOP_AVAILABLE = False
# ---------------------------------------------------------------------------
# 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"}
# Actions VWB de type data loop
_DATA_LOOP_ACTION_TYPES = {"import_excel", "db_foreach"}
# Actions VWB de gestion de fichiers
_FILE_ACTION_TYPES = {
"file_list_dir", "file_create_dir", "file_move",
"file_copy", "file_sort_by_ext",
}
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
# import_excel et db_foreach sont des UI_ACTION traitées spécialement
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
# ---------------------------------------------------------------------------
# Exécution des étapes Data Loop (import_excel, db_foreach)
# ---------------------------------------------------------------------------
_CURRENT_ROW_PATTERN = re.compile(r"\$\{current_row\.(\w+)\}")
def _execute_import_excel(parameters: Dict[str, Any]) -> Dict[str, Any]:
"""Exécute l'import d'un fichier Excel dans la base SQLite.
Args:
parameters: Doit contenir 'file_path', optionnellement 'table_name', 'sheet_name'
Returns:
Dict avec les infos d'import (table_name, row_count, columns)
"""
if not _DATA_LOOP_AVAILABLE:
raise RuntimeError(
"Module core.data non disponible. "
"Vérifiez que core/data/excel_importer.py existe."
)
file_path = parameters.get("file_path", "")
if not file_path:
raise ValueError("Paramètre 'file_path' requis pour import_excel")
table_name = parameters.get("table_name") or None
sheet_name = parameters.get("sheet_name") or None
# Convertir sheet_name en int si c'est un index numérique
if sheet_name and sheet_name.isdigit():
sheet_name = int(sheet_name)
importer = ExcelImporter()
result = importer.import_file(
file_path=file_path,
table_name=table_name,
sheet_name=sheet_name,
)
logger.info(
"Import Excel terminé : %s → table '%s' (%d lignes, colonnes: %s)",
file_path,
result.table_name,
result.row_count,
result.columns,
)
return {
"table_name": result.table_name,
"row_count": result.row_count,
"columns": result.columns,
"db_path": str(result.db_path) if hasattr(result, "db_path") else None,
}
def _inject_current_row(
action: Dict[str, Any], row: Dict[str, Any]
) -> Dict[str, Any]:
"""Remplace les références ${current_row.column} dans les paramètres d'une action.
Args:
action: Dict des paramètres de l'étape
row: Dict représentant la ligne courante (colonne → valeur)
Returns:
Copie de l'action avec les références remplacées
"""
import copy
resolved = copy.deepcopy(action)
def _resolve(obj: Any) -> Any:
if isinstance(obj, str):
# Cas spécial : la chaîne entière est une seule référence
m = _CURRENT_ROW_PATTERN.fullmatch(obj)
if m:
col = m.group(1)
return row.get(col, obj)
# Cas général : remplacement inline
def _replacer(match: re.Match) -> str:
col = match.group(1)
val = row.get(col)
return str(val) if val is not None else match.group(0)
return _CURRENT_ROW_PATTERN.sub(_replacer, obj)
elif isinstance(obj, dict):
return {k: _resolve(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_resolve(item) for item in obj]
return obj
return _resolve(resolved)
def _execute_db_foreach(
foreach_step_data: Dict[str, Any],
all_steps_data: List[Dict[str, Any]],
edges_data: List[Dict[str, Any]],
executor_kwargs: Dict[str, Any],
) -> Dict[str, Any]:
"""Exécute une boucle db_foreach : lit les lignes puis ré-exécute les étapes dépendantes.
Algorithme :
1. Lire toutes les lignes de la table (avec filtres optionnels)
2. Identifier les étapes qui dépendent du foreach (via les edges)
3. Pour chaque ligne, injecter ${current_row.column} et exécuter ces étapes
Args:
foreach_step_data: Les données de l'étape db_foreach
all_steps_data: Toutes les étapes du workflow
edges_data: Les edges du workflow
executor_kwargs: Paramètres pour le DAGExecutor (model, ollama_endpoint, timeout)
Returns:
Dict avec le résumé de l'exécution de la boucle
"""
if not _DATA_LOOP_AVAILABLE:
raise RuntimeError(
"Module core.data non disponible. "
"Vérifiez que core/data/db_iterator.py existe."
)
parameters = foreach_step_data.get("parameters", {})
table_name = parameters.get("table_name", "")
if not table_name:
raise ValueError("Paramètre 'table_name' requis pour db_foreach")
where_clause = parameters.get("where_clause") or None
order_by = parameters.get("order_by") or None
limit = parameters.get("limit")
if limit is not None:
try:
limit = int(limit)
except (ValueError, TypeError):
limit = None
# 1. Lire les lignes
iterator = DBIterator()
rows = list(iterator.iterate(
table_name=table_name,
where=where_clause,
order_by=order_by,
limit=limit,
))
if not rows:
logger.info("db_foreach : table '%s' vide, aucune itération", table_name)
return {"row_count": 0, "iterations": [], "table_name": table_name}
logger.info(
"db_foreach : %d lignes à traiter dans '%s'", len(rows), table_name
)
# 2. Identifier les étapes dépendantes du foreach
foreach_id = foreach_step_data["id"]
dependent_ids = set()
for edge in edges_data:
if edge.get("source") == foreach_id:
dependent_ids.add(edge["target"])
# Aussi inclure les étapes qui dépendent des dépendants directs (chaîne)
changed = True
while changed:
changed = False
for edge in edges_data:
if edge.get("source") in dependent_ids and edge["target"] not in dependent_ids:
dependent_ids.add(edge["target"])
changed = True
if not dependent_ids:
logger.warning("db_foreach '%s' : aucune étape dépendante trouvée", foreach_id)
return {"row_count": len(rows), "iterations": [], "table_name": table_name}
# Filtrer les étapes et edges pour ne garder que le sous-graphe
sub_steps = [s for s in all_steps_data if s["id"] in dependent_ids]
sub_edges = [
e for e in edges_data
if e.get("source") in dependent_ids and e.get("target") in dependent_ids
]
# 3. Pour chaque ligne, injecter et exécuter
iteration_results = []
model = executor_kwargs.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
ollama_endpoint = executor_kwargs.get("ollama_endpoint", "http://localhost:11434")
timeout = executor_kwargs.get("timeout", 300)
for row_idx, row in enumerate(rows):
logger.info(
"db_foreach iteration %d/%d : %s",
row_idx + 1, len(rows), {k: str(v)[:50] for k, v in row.items()}
)
# Injecter ${current_row.xxx} dans les paramètres de chaque étape
injected_steps = []
for step_data in sub_steps:
injected = dict(step_data)
injected["parameters"] = _inject_current_row(
step_data.get("parameters", {}), row
)
injected_steps.append(injected)
# Reconstruire les edges internes (sans la dépendance vers foreach)
internal_edges = []
for e in sub_edges:
internal_edges.append(e)
# Pour les étapes qui dépendaient du foreach, supprimer cette dépendance
# (elles deviennent des racines du sous-DAG)
root_ids = set()
for edge in edges_data:
if edge.get("source") == foreach_id and edge["target"] in dependent_ids:
root_ids.add(edge["target"])
# Convertir en DAG steps
dag_steps = _convert_vwb_to_dag_steps(injected_steps, internal_edges)
# Supprimer la dépendance vers foreach_id dans les étapes racines
for ds in dag_steps:
ds.depends_on = [d for d in ds.depends_on if d != foreach_id]
# Vérifier s'il y a des étapes LLM
has_llm = any(s.step_type == StepType.LLM_CALL for s in dag_steps)
llm_handler = None
if has_llm:
llm_handler = LLMActionHandler(
ollama_endpoint=ollama_endpoint,
model=model,
)
executor = DAGExecutor(
max_llm_workers=2,
max_ui_workers=1,
llm_handler=llm_handler,
)
executor.load_workflow(dag_steps)
result = executor.execute(timeout=timeout)
iteration_results.append({
"row_index": row_idx,
"row_data": row,
"success": result.success,
"results": result.results,
"errors": result.errors,
"duration": result.duration_seconds,
})
if not result.success:
logger.warning(
"db_foreach iteration %d échouée : %s",
row_idx, result.errors,
)
# Résumé
success_count = sum(1 for r in iteration_results if r["success"])
return {
"table_name": table_name,
"row_count": len(rows),
"success_count": success_count,
"error_count": len(rows) - success_count,
"iterations": iteration_results,
}
# ---------------------------------------------------------------------------
# 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", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434")
executor_kwargs = {
"timeout": timeout,
"model": model,
"ollama_endpoint": ollama_endpoint,
}
# ---------------------------------------------------------------
# Pré-traitement des étapes Data Loop (import_excel, db_foreach)
# ---------------------------------------------------------------
data_loop_results: Dict[str, Any] = {}
# 1. Exécuter les imports Excel en premier (avant le DAG principal)
import_steps = [s for s in steps_data if s["action_type"] == "import_excel"]
for imp_step in import_steps:
try:
imp_result = _execute_import_excel(imp_step.get("parameters", {}))
data_loop_results[imp_step["id"]] = imp_result
logger.info(
"Import Excel '%s' terminé : %s",
imp_step["id"], imp_result,
)
except Exception as exc:
logger.error("Import Excel '%s' échoué : %s", imp_step["id"], exc)
return jsonify({
'success': False,
'error': f"Import Excel échoué : {exc}",
}), 500
# 2. Traiter les étapes db_foreach séparément
foreach_steps = [s for s in steps_data if s["action_type"] == "db_foreach"]
for fe_step in foreach_steps:
try:
fe_result = _execute_db_foreach(
fe_step, steps_data, edges_data, executor_kwargs,
)
data_loop_results[fe_step["id"]] = fe_result
logger.info(
"db_foreach '%s' terminé : %d lignes, %d succès",
fe_step["id"],
fe_result.get("row_count", 0),
fe_result.get("success_count", 0),
)
except Exception as exc:
logger.error("db_foreach '%s' échoué : %s", fe_step["id"], exc)
return jsonify({
'success': False,
'error': f"Boucle données échouée : {exc}",
}), 500
# 3. Filtrer les étapes data loop + leurs dépendants (déjà exécutés)
# pour le DAG principal
foreach_ids = {s["id"] for s in foreach_steps}
already_executed_ids = set()
for fe_id in foreach_ids:
already_executed_ids.add(fe_id)
# Trouver récursivement tous les dépendants
changed = True
while changed:
changed = False
for edge in edges_data:
if (edge.get("source") in already_executed_ids
and edge["target"] not in already_executed_ids):
already_executed_ids.add(edge["target"])
changed = True
# Ajouter les import_excel aux déjà exécutés
for imp_step in import_steps:
already_executed_ids.add(imp_step["id"])
# Filtrer les étapes restantes pour le DAG principal
remaining_steps = [s for s in steps_data if s["id"] not in already_executed_ids]
remaining_edges = [
e for e in edges_data
if e.get("source") not in already_executed_ids
and e.get("target") not in already_executed_ids
]
# Si toutes les étapes sont des data loop, retourner directement
if not remaining_steps:
return jsonify({
'success': True,
'execution': {
"success": True,
"data_loop_results": data_loop_results,
"steps": {},
"results": {},
"errors": [],
"duration_seconds": 0,
},
})
# ---------------------------------------------------------------
# Exécution DAG normale pour les étapes restantes
# ---------------------------------------------------------------
# Convertir en étapes DAG
dag_steps = _convert_vwb_to_dag_steps(remaining_steps, remaining_edges)
# Supprimer les dépendances vers des étapes data loop déjà exécutées
for ds in dag_steps:
ds.depends_on = [d for d in ds.depends_on if d not in already_executed_ids]
# 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,
)
# Injecter les résultats des étapes data loop dans l'exécuteur
# pour qu'ils soient disponibles via ${step_id.result}
for step_id, dl_result in data_loop_results.items():
executor._results[step_id] = dl_result
# 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, %d data loop pré-traités)",
workflow_id,
len(dag_steps),
sum(1 for s in dag_steps if s.step_type == StepType.LLM_CALL),
len(data_loop_results),
)
# Exécuter (bloquant — le timeout protège)
result = executor.execute(timeout=timeout)
_last_result = result
# Fusionner les résultats data loop
result_dict = result.to_dict()
if data_loop_results:
result_dict["data_loop_results"] = data_loop_results
logger.info(
"Exécution DAG terminée : success=%s, durée=%.2fs",
result.success,
result.duration_seconds,
)
return jsonify({
'success': True,
'execution': result_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
# ---------------------------------------------------------------------------
# Upload Excel — explorateur de fichier côté navigateur
# ---------------------------------------------------------------------------
UPLOAD_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'data', 'uploads'
)
@api_v3_bp.route('/upload-excel', methods=['POST'])
def upload_excel():
"""Reçoit un fichier Excel uploadé depuis le navigateur.
Sauvegarde dans data/uploads/ et retourne le chemin serveur +
un nom de table suggéré basé sur le nom du fichier.
"""
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier reçu'}), 400
file = request.files['file']
if not file.filename:
return jsonify({'error': 'Nom de fichier vide'}), 400
# Vérifier l'extension
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ('.xlsx', '.xls'):
return jsonify({'error': f'Format non supporté : {ext}'}), 400
os.makedirs(UPLOAD_DIR, exist_ok=True)
save_path = os.path.join(UPLOAD_DIR, file.filename)
file.save(save_path)
# Nom de table suggéré à partir du nom du fichier
import re as _re
base = os.path.splitext(file.filename)[0]
suggested = _re.sub(r'[^a-zA-Z0-9_]', '_', base).strip('_').lower()
if suggested and suggested[0].isdigit():
suggested = 't_' + suggested
logger.info(f"Excel uploadé : {save_path} → table suggérée : {suggested}")
return jsonify({
'path': save_path,
'filename': file.filename,
'suggested_table': suggested,
})
# ---------------------------------------------------------------------------
# Exécution sur Windows — proxy vers le streaming server (port 5005)
# ---------------------------------------------------------------------------
def _load_anchor_image_b64(anchor_id: str) -> Optional[str]:
"""Charger l'image d'une ancre et la retourner en base64.
Cherche dans 3 emplacements possibles :
1. data/anchors/{id}_full.png (nouveau format V3)
2. data/anchor_images/{id}/original.png (ancien format)
3. SQLite visual_anchors.image_path (chemin absolu en BDD)
"""
import base64 as b64
backend_dir = Path(__file__).resolve().parent.parent
# 1. Nouveau format : data/anchors/{id}_thumb.png (crop de l'ancre, pas le screenshot complet)
new_path = backend_dir / 'data' / 'anchors' / f'{anchor_id}_thumb.png'
if new_path.exists():
try:
with open(new_path, 'rb') as f:
return b64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error("Erreur lecture ancre %s : %s", new_path, e)
# 2. Ancien format : data/anchor_images/{id}/original.png
old_path = backend_dir / 'data' / 'anchor_images' / anchor_id / 'original.png'
if old_path.exists():
try:
with open(old_path, 'rb') as f:
return b64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error("Erreur lecture ancre %s : %s", old_path, e)
# 3. Chemin depuis la BDD
try:
import sqlite3
db_path = backend_dir / 'instance' / 'workflows.db'
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT image_path FROM visual_anchors WHERE id=?", (anchor_id,)).fetchone()
conn.close()
if row and row[0] and Path(row[0]).exists():
with open(row[0], 'rb') as f:
return b64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error("Erreur lecture ancre BDD %s : %s", anchor_id, e)
logger.warning("Image ancre introuvable pour %s", anchor_id)
return None
def _load_anchor_metadata(anchor_id: str) -> Optional[Dict]:
"""Charger les métadonnées d'une ancre (bounding_box, taille, etc.)."""
backend_dir = Path(__file__).resolve().parent.parent
# 1. Ancien format : metadata.json
meta_path = backend_dir / 'data' / 'anchor_images' / anchor_id / 'metadata.json'
if meta_path.exists():
try:
with open(meta_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
# 2. Depuis la BDD visual_anchors
try:
import sqlite3
db_path = backend_dir / 'instance' / 'workflows.db'
conn = sqlite3.connect(str(db_path))
row = conn.execute(
"SELECT bbox_x, bbox_y, bbox_width, bbox_height, screen_width, screen_height "
"FROM visual_anchors WHERE id=?", (anchor_id,)
).fetchone()
conn.close()
if row:
return {
'bounding_box': {'x': row[0], 'y': row[1], 'width': row[2], 'height': row[3]},
'original_size': {'width': row[4] or 1920, 'height': row[5] or 1080},
}
except Exception:
pass
return None
def _inject_anchor_targeting(action: Dict, anchor_id: str) -> None:
"""Enrichit une action avec la cible visuelle (x_pct/y_pct + visual_mode/target_spec).
Mutation in-place de `action`. Utilisé pour click_anchor*, type_text et
type_secret — toute action qui doit cibler une zone visuelle précise avant
d'agir (clic ou frappe avec focus).
Sans cette injection, l'agent côté Windows ne peut pas faire le pre-click
de focus avant `_type_text`, et le texte tape dans le vide.
"""
if not anchor_id:
return
anchor_meta = _load_anchor_metadata(anchor_id)
# Coordonnées du centre du bbox (fallback si template matching échoue)
if anchor_meta:
bbox = anchor_meta.get('bounding_box', {})
orig = anchor_meta.get('original_size', {})
orig_w = orig.get('width', 1920)
orig_h = orig.get('height', 1080)
if bbox.get('x') is not None and orig_w > 0 and orig_h > 0:
cx = (bbox['x'] + bbox.get('width', 0) / 2) / orig_w
cy = (bbox['y'] + bbox.get('height', 0) / 2) / orig_h
action['x_pct'] = round(cx, 4)
action['y_pct'] = round(cy, 4)
# Image de l'ancre pour template matching côté agent
anchor_b64 = _load_anchor_image_b64(anchor_id)
if anchor_b64:
target_spec = {
'anchor_image_base64': anchor_b64,
'anchor_id': anchor_id,
}
if anchor_meta:
target_spec['anchor_bbox'] = anchor_meta.get('bounding_box', {})
target_spec['original_size'] = anchor_meta.get('original_size', {})
action['visual_mode'] = True
action['target_spec'] = target_spec
logger.info(
"Action %s : ancre '%s' chargée (%d Ko), visual_mode activé",
action.get('action_id', '?'),
anchor_id,
len(anchor_b64) // 1024,
)
else:
logger.warning(
"Action %s : ancre '%s' introuvable, fallback blind mode",
action.get('action_id', '?'),
anchor_id,
)
@api_v3_bp.route('/execute-windows', methods=['POST'])
def execute_windows():
"""Proxy les actions du workflow vers le streaming server pour exécution sur Windows.
Le navigateur ne peut pas contacter le port 5005 directement (CORS/réseau),
donc le backend VWB sert de proxy.
Pour les actions click_anchor, charge l'image de l'ancre visuelle depuis le
disque et l'inclut en base64 dans target_spec afin que l'exécuteur Windows
puisse résoudre la position par template matching (visual_mode).
"""
import requests as req
data = request.get_json()
if not data:
return jsonify({'error': 'Aucune donnée'}), 400
# Vérifier si ce sont uniquement des actions fichiers (pas besoin de wait ni replay)
all_file_actions = all(
a.get('type', '') in _FILE_ACTION_TYPES
for a in data.get('actions', [])
) if data.get('actions') else False
# Injecter un délai de 5s SEULEMENT pour les actions UI (pas les fichiers)
if not all_file_actions and 'actions' in data and data['actions']:
data['actions'].insert(0, {
'type': 'wait',
'action_id': 'wait_before_start',
'parameters': {'duration_ms': 5000},
'text': '',
})
# Mapper les types VWB → types executor Windows
TYPE_MAP = {
'click_anchor': 'click',
'double_click_anchor': 'click',
'right_click_anchor': 'click',
'type_text': 'type',
'type_secret': 'type',
'keyboard_shortcut': 'key_combo',
'hotkey': 'key_combo',
'scroll_to_anchor': 'scroll',
'wait_for_anchor': 'wait',
'visual_condition': 'wait',
}
# Types d'actions basées sur une ancre visuelle (nécessitent visual_mode)
_ANCHOR_CLICK_TYPES = {'click_anchor', 'double_click_anchor', 'right_click_anchor'}
if 'actions' in data:
for action in data['actions']:
vwb_type = action.get('type', '')
params = action.get('parameters', {})
# Mapper le type VWB → type executor
mapped_type = TYPE_MAP.get(vwb_type, vwb_type)
action['type'] = mapped_type
# ---------------------------------------------------------------
# Actions basées sur ancre visuelle → injecter visual_mode
# ---------------------------------------------------------------
if vwb_type in _ANCHOR_CLICK_TYPES:
anchor_id = action.get('anchor_id')
if anchor_id:
_inject_anchor_targeting(action, anchor_id)
# Propagation du by_text (ciblage textuel prioritaire sur template)
_by_text = params.get('by_text', '')
if _by_text:
action['by_text'] = _by_text
if 'target_spec' in action:
action['target_spec']['by_text'] = _by_text
# Mapper le bouton selon le type de clic VWB
if vwb_type == 'double_click_anchor':
action['button'] = 'double'
elif vwb_type == 'right_click_anchor':
action['button'] = 'right'
# ---------------------------------------------------------------
# type_text / type_secret → extraire le texte + cibler la zone
# de saisie si une ancre visuelle est associée au step.
# Sans ancre, l'agent tape là où le focus se trouve déjà
# (compatibilité avec les workflows historiques sans anchor).
# ---------------------------------------------------------------
if vwb_type in ('type_text', 'type_secret') and 'text' in params:
action['text'] = params['text']
anchor_id = action.get('anchor_id') or (
params.get('visual_anchor') or {}
).get('anchor_id')
if anchor_id:
_inject_anchor_targeting(action, anchor_id)
# ---------------------------------------------------------------
# keyboard_shortcut / hotkey → extraire les touches
# ---------------------------------------------------------------
if vwb_type in ('keyboard_shortcut', 'hotkey') and 'keys' in params:
action['keys'] = params['keys']
# ---------------------------------------------------------------
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
# ---------------------------------------------------------------
if 'actions' in data:
file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
if file_actions:
# Exécuter les actions fichiers via l'agent Windows
# Auth : Bearer token obligatoire (capture_server.py exige RPA_API_TOKEN)
_agent_host = os.environ.get('RPA_WINDOWS_AGENT_HOST', '192.168.1.11')
_agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
_agent_url = f'http://{_agent_host}:{_agent_port}/file-action'
_api_token = os.environ.get('RPA_API_TOKEN', '')
_file_headers = {'Authorization': f'Bearer {_api_token}'} if _api_token else {}
file_results = []
for fa in file_actions:
try:
fa_resp = req.post(
_agent_url,
json={
'action': fa['type'],
'params': fa.get('parameters', {}),
},
headers=_file_headers,
timeout=30,
)
if fa_resp.status_code == 401:
file_results.append({
'error': "Agent Windows : auth refusee (verifier RPA_API_TOKEN)",
})
else:
file_results.append(fa_resp.json())
except req.ConnectionError:
file_results.append({
'error': f"Agent Windows ({_agent_host}:{_agent_port}) non disponible pour l'action fichier"
})
except Exception as e:
file_results.append({'error': str(e)})
# Si TOUTES les actions sont des actions fichiers, retourner directement
non_file_actions = [a for a in data['actions'] if a.get('type', '') not in _FILE_ACTION_TYPES]
if not non_file_actions:
return jsonify({
'success': all('error' not in r for r in file_results),
'file_results': file_results,
})
# Sinon, retirer les actions fichiers du flux principal
data['actions'] = non_file_actions
# Token Bearer pour le streaming server (auth obligatoire)
_stream_token = os.environ.get('RPA_API_TOKEN', '')
_stream_headers = {'Authorization': f'Bearer {_stream_token}'} if _stream_token else {}
# L'agent Windows poll sous session "agent_demo_user" (= agent_{user_id}, user_id="demo_user")
# On injecte directement dans cette session pour éviter le transfer cross-session
# et pour que /replay/raw ne tente pas l'auto-détection d'une session "sess_*"
# (qui échoue avec "Aucune session Agent V1 active" si l'agent n'a pas créé de session V1).
if not data.get('session_id'):
data['session_id'] = 'agent_demo_user'
# Forcer le mode supervisé : pause_for_human DÉCLENCHE au lieu d'être
# skippée. Le médecin valide la décision Léa avant que les saisies
# type_text ne s'exécutent dans l'onglet Codage. Crucial pour la démo
# GHT : Léa propose, humain valide, Léa finalise (cf. workflow Urgence).
# Sans ça, mode "autonomous" par défaut → pause skippée → saisies
# tentées sans validation → désordre visuel.
data.setdefault('params', {})
data['params'].setdefault('execution_mode', 'supervised')
# Injecter le machine_id pour le ciblage multi-machine.
# Cibler la machine Windows la plus récemment active (heartbeat last_activity)
# plutôt que la première dans l'ordre arbitraire renvoyé par /machines :
# un workflow enregistré sur PC A doit pouvoir être rejoué sur PC B (vision
# 100 % visuelle, recalcul anchors+coords selon la résolution courante).
# Le workflow.machine_id signale l'origine d'enregistrement, pas la cible
# d'exécution — la cible doit être l'agent qui POLLE actuellement.
if 'machine_id' not in data or not data.get('machine_id'):
try:
machines_resp = req.get(
'http://localhost:5005/api/v1/traces/stream/machines',
headers=_stream_headers,
timeout=3,
)
if machines_resp.ok:
machines = machines_resp.json().get('machines', [])
# Filtrer Windows + non default, trier par last_activity desc
windows_machines = [
m for m in machines
if m.get('machine_id')
and m['machine_id'] != 'default'
and 'windows' in m['machine_id'].lower()
]
windows_machines.sort(
key=lambda m: m.get('last_activity', ''),
reverse=True,
)
if windows_machines:
data['machine_id'] = windows_machines[0]['machine_id']
except Exception:
pass
try:
resp = req.post(
'http://localhost:5005/api/v1/traces/stream/replay/raw',
json=data,
headers=_stream_headers,
timeout=30,
)
return jsonify(resp.json()), resp.status_code
except req.ConnectionError:
return jsonify({'error': 'Streaming server (port 5005) non disponible'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# QW4 — Proxy /api/v3/replay/resume → streaming /replay/{id}/resume
# Forward Bearer token + body { replay_id, acknowledged_check_ids }.
# Le frontend (PauseDialog) appelle /api/v3/replay/resume via le VWB ;
# on relaye au streaming server pour valider les acquittements safety_checks.
# ---------------------------------------------------------------------------
@api_v3_bp.route('/replay/resume', methods=['POST'])
def replay_resume_proxy():
"""Proxy QW4 vers le serveur streaming pour la reprise avec safety_checks."""
import requests as req
data = request.get_json() or {}
replay_id = data.get('replay_id')
if not replay_id:
return jsonify({'error': 'replay_id manquant'}), 400
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
token = os.environ.get('RPA_API_TOKEN', '')
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
# Body forwardé : uniquement acknowledged_check_ids (replay_id est dans l'URL)
forward_body = {
'acknowledged_check_ids': data.get('acknowledged_check_ids') or [],
}
try:
resp = req.post(
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}/resume',
json=forward_body,
headers=headers,
timeout=10,
)
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
except req.ConnectionError:
return jsonify({'error': 'streaming_unreachable',
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
# ---------------------------------------------------------------------------
# QW4 — Proxy GET /api/v3/replay/state/<replay_id> → streaming /replay/{id}
# Forward Bearer token vers le serveur streaming.
# Permet à App.tsx de récupérer le state du replay actif (Agent V1 Windows)
# pour afficher PauseDialog quand status = paused_need_help avec safety_checks.
# ---------------------------------------------------------------------------
@api_v3_bp.route('/replay/state/<replay_id>', methods=['GET'])
def replay_state_proxy(replay_id):
"""Proxy QW4 vers le serveur streaming pour récupérer le state replay actif."""
import requests as req
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
token = os.environ.get('RPA_API_TOKEN', '')
headers = {}
if token:
headers['Authorization'] = f'Bearer {token}'
try:
resp = req.get(
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}',
headers=headers,
timeout=5,
)
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
except req.ConnectionError:
return jsonify({'error': 'streaming_unreachable',
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502