refactor(audit): Nettoyage dette technique phases 1-4

Phase 1 — Code mort et duplication :
- Supprimer catalog_routes.py (-1832 lignes, doublon de v2_vlm)
- Mettre à jour app.py et app_lightweight.py vers catalog_routes_v2_vlm
- Nettoyer 9 imports inutilisés dans catalog_routes_v2_vlm.py
- Supprimer get_required_params inutilisé dans execute.py

Phase 2 — Centraliser la configuration :
- Ollama URL via os.environ.get() dans verify_text_content.py et extraire_tableau.py
- MODEL_PATH relatif au projet + var env UI_DETR_MODEL_PATH dans ui_detection_service.py

Phase 3 — Thread-safety de l'exécution :
- Ajouter _execution_lock (RLock) pour protéger _execution_state
- Remplacer le polling self-healing par threading.Event
- Initialiser 'variables' dans le dict initial (plus de création dynamique)
- Corriger bare except → except Exception as db_err avec message

Phase 4 — Logging minimal :
- Ajouter logger dans execute.py, remplacer print() critiques par logger
- Configurer RotatingFileHandler (5MB, 3 backups) dans app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-02-17 08:11:45 +01:00
parent a27b74cf22
commit 3ff36e3c79
8 changed files with 322 additions and 1992 deletions

View File

@@ -14,6 +14,7 @@ Cas d'usage :
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime from datetime import datetime
import os
import time import time
import base64 import base64
import io import io
@@ -26,9 +27,9 @@ from ...contracts.error import VWBErrorType, create_vwb_error
from ...contracts.visual_anchor import VWBVisualAnchor from ...contracts.visual_anchor import VWBVisualAnchor
# Configuration par défaut # Configuration par défaut (centralisée via variable d'environnement)
OLLAMA_DEFAULT_URL = "http://localhost:11434" OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = "qwen2.5-vl:7b" OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen2.5-vl:7b")
class VWBExtraireTableauAction(BaseVWBAction): class VWBExtraireTableauAction(BaseVWBAction):

View File

@@ -12,6 +12,7 @@ Modes OCR disponibles:
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime
import os
import time import time
import re import re
import base64 import base64
@@ -36,9 +37,9 @@ class VWBVerifyTextContentAction(BaseVWBAction):
- easyocr: OCR traditionnel (plus rapide, fallback) - easyocr: OCR traditionnel (plus rapide, fallback)
""" """
# Configuration Ollama par défaut # Configuration Ollama par défaut (centralisée via variable d'environnement)
OLLAMA_URL = "http://localhost:11434" OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = "qwen2.5-vl:7b" # Modèle de vision Qwen - excellent pour OCR OLLAMA_MODEL = os.environ.get("VLM_MODEL", "qwen2.5-vl:7b") # Modèle de vision Qwen - excellent pour OCR
def __init__( def __init__(
self, self,

View File

@@ -16,9 +16,68 @@ import threading
import time import time
import base64 import base64
import os import os
import logging
import subprocess import subprocess
from . import api_v3_bp from . import api_v3_bp
logger = logging.getLogger(__name__)
def safe_type_text(text):
"""Saisie de texte compatible avec tous les claviers (AZERTY/QWERTY).
pyautogui.write() envoie des codes de touches QWERTY qui produisent
des caractères erronés sur AZERTY (* → µ, ( → 5, ) → °, etc.).
Priorité :
1. Presse-papier (xclip) + Ctrl+V → instantané, fiable pour tout texte
2. xdotool type → respecte le layout clavier
3. pyautogui.write() → dernier recours
"""
import shutil
import pyautogui
# Méthode 1 : Presse-papier (le plus fiable, gère UTF-8/accents/CJK)
# xclip reste en vie comme daemon X11 pour servir le clipboard.
# On ne doit PAS attendre sa terminaison — juste envoyer les données
# et coller immédiatement.
xclip = shutil.which('xclip')
if xclip:
try:
p = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
p.stdin.write(text.encode('utf-8'))
p.stdin.close()
# Ne PAS attendre xclip — il reste vivant comme owner du clipboard
time.sleep(0.2)
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.3)
print(f" ✅ Saisie via presse-papier ({len(text)} car.)")
return
except Exception as e:
print(f" ⚠️ xclip échoué: {e}")
# Méthode 2 : xdotool type (respecte le layout clavier actif)
if shutil.which('xdotool'):
try:
subprocess.run(
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
timeout=max(30, len(text) * 0.05),
check=True
)
print(f" ✅ Saisie via xdotool type ({len(text)} car.)")
return
except Exception as e:
print(f" ⚠️ xdotool type échoué: {e}")
# Méthode 3 : pyautogui (dernier recours — problèmes AZERTY possibles)
print(" ⚠️ Saisie via pyautogui.write() (AZERTY non garanti)")
pyautogui.write(text)
def minimize_active_window(): def minimize_active_window():
"""Minimise la fenêtre active (Linux avec xdotool)""" """Minimise la fenêtre active (Linux avec xdotool)"""
@@ -37,7 +96,7 @@ def minimize_active_window():
print(f"⚠️ [Execute] Erreur minimisation: {e}") print(f"⚠️ [Execute] Erreur minimisation: {e}")
return False return False
from db.models import db, Workflow, Step, Execution, ExecutionStep, VisualAnchor, get_session_state from db.models import db, Workflow, Step, Execution, ExecutionStep, VisualAnchor, get_session_state
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params from contracts.action_contracts import enforce_action_contract, ContractValidationError
def generate_id(prefix: str) -> str: def generate_id(prefix: str) -> str:
@@ -45,6 +104,14 @@ def generate_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}" return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
# Thread-safety : verrou pour protéger _execution_state (accès R/W depuis le
# thread d'exécution ET les handlers HTTP Flask en parallèle).
_execution_lock = threading.RLock()
# Event pour remplacer le polling actif du self-healing :
# le thread d'exécution attend (.wait), le handler HTTP signale (.set).
_healing_event = threading.Event()
# État de l'exécution en cours (en mémoire) # État de l'exécution en cours (en mémoire)
_execution_state = { _execution_state = {
'is_running': False, 'is_running': False,
@@ -53,6 +120,7 @@ _execution_state = {
'current_execution_id': None, 'current_execution_id': None,
'thread': None, 'thread': None,
'execution_mode': 'basic', # 'basic', 'intelligent', 'debug' 'execution_mode': 'basic', # 'basic', 'intelligent', 'debug'
'variables': {}, # Variables runtime du workflow (initialisé ici, plus de création dynamique)
# Self-healing interactif # Self-healing interactif
'waiting_for_choice': False, 'waiting_for_choice': False,
'pending_action': None, # Action en attente de choix utilisateur 'pending_action': None, # Action en attente de choix utilisateur
@@ -75,7 +143,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
workflow = Workflow.query.get(workflow_id) workflow = Workflow.query.get(workflow_id)
if not execution or not workflow: if not execution or not workflow:
print(f"❌ [Execute] Workflow ou exécution non trouvé") logger.error("Workflow ou exécution non trouvé")
return return
steps = workflow.steps.order_by(Step.order).all() steps = workflow.steps.order_by(Step.order).all()
@@ -84,7 +152,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.started_at = datetime.utcnow() execution.started_at = datetime.utcnow()
db.session.commit() db.session.commit()
print(f"🚀 [Execute] Démarrage workflow {workflow_id}: {len(steps)} étapes") logger.info(f"Démarrage workflow {workflow_id}: {len(steps)} étapes")
for index, step in enumerate(steps): for index, step in enumerate(steps):
# Vérifier si arrêt demandé # Vérifier si arrêt demandé
@@ -155,7 +223,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
try: try:
enforce_action_contract(step.action_type, params) enforce_action_contract(step.action_type, params)
except ContractValidationError as e: except ContractValidationError as e:
print(f"🚫 [Execute] CONTRAT VIOLÉ pour étape {step.id}: {e}") logger.warning(f"CONTRAT VIOLÉ pour étape {step.id}: {e}")
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = f"Contrat violé: {str(e)}" step_result.error_message = f"Contrat violé: {str(e)}"
step_result.ended_at = datetime.utcnow() step_result.ended_at = datetime.utcnow()
@@ -176,34 +244,31 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}") print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}")
# Stocker les informations pour le frontend # Stocker les informations pour le frontend
_execution_state['waiting_for_choice'] = True with _execution_lock:
_execution_state['pending_action'] = { _healing_event.clear()
'step_id': step.id, _execution_state['waiting_for_choice'] = True
'step_index': index, _execution_state['pending_action'] = {
'action_type': step.action_type, 'step_id': step.id,
'params': params 'step_index': index,
} 'action_type': step.action_type,
_execution_state['candidates'] = result.get('candidates', []) 'params': params
_execution_state['current_step_info'] = { }
'index': index, _execution_state['candidates'] = result.get('candidates', [])
'total': len(steps), _execution_state['current_step_info'] = {
'original_bbox': result.get('original_bbox'), 'index': index,
'error': result.get('error') 'total': len(steps),
} 'original_bbox': result.get('original_bbox'),
_execution_state['user_choice'] = None 'error': result.get('error')
}
_execution_state['user_choice'] = None
# Mettre à jour le status de l'exécution # Mettre à jour le status de l'exécution
execution.status = 'waiting_user_choice' execution.status = 'waiting_user_choice'
db.session.commit() db.session.commit()
# Attendre le choix de l'utilisateur # Attendre le choix de l'utilisateur (Event au lieu de polling)
timeout_seconds = 120 # 2 minutes max timeout_seconds = 120 # 2 minutes max
waited = 0 _healing_event.wait(timeout=timeout_seconds)
while _execution_state['waiting_for_choice'] and waited < timeout_seconds:
if _execution_state['should_stop']:
break
time.sleep(0.5)
waited += 0.5
# Vérifier si on doit arrêter # Vérifier si on doit arrêter
if _execution_state['should_stop']: if _execution_state['should_stop']:
@@ -211,10 +276,11 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
break break
# Traiter le choix de l'utilisateur # Traiter le choix de l'utilisateur
user_choice = _execution_state['user_choice'] with _execution_lock:
_execution_state['waiting_for_choice'] = False user_choice = _execution_state['user_choice']
_execution_state['pending_action'] = None _execution_state['waiting_for_choice'] = False
_execution_state['candidates'] = [] _execution_state['pending_action'] = None
_execution_state['candidates'] = []
if user_choice is None: if user_choice is None:
# Timeout - aucun choix # Timeout - aucun choix
@@ -261,7 +327,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = result.get('error', 'Erreur inconnue') step_result.error_message = result.get('error', 'Erreur inconnue')
execution.failed_steps += 1 execution.failed_steps += 1
print(f"❌ [Execute] Étape {index + 1} échouée: {step_result.error_message}") logger.warning(f"Étape {index + 1} échouée: {step_result.error_message}")
# Arrêter sur erreur # Arrêter sur erreur
execution.status = 'error' execution.status = 'error'
@@ -272,7 +338,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
print(f"❌ [Execute] Exception étape {index + 1}: {e}") logger.error(f"Exception étape {index + 1}: {e}", exc_info=True)
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = str(e) step_result.error_message = str(e)
step_result.ended_at = datetime.utcnow() step_result.ended_at = datetime.utcnow()
@@ -289,11 +355,10 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.ended_at = datetime.utcnow() execution.ended_at = datetime.utcnow()
db.session.commit() db.session.commit()
print(f"🏁 [Execute] Workflow terminé: {execution.status}") logger.info(f"Workflow terminé: {execution.status} (complétées: {execution.completed_steps}, échouées: {execution.failed_steps})")
print(f" Complétées: {execution.completed_steps}, Échouées: {execution.failed_steps}")
except Exception as e: except Exception as e:
print(f"❌ [Execute] Erreur fatale: {e}") logger.error(f"Erreur fatale: {e}", exc_info=True)
try: try:
execution = Execution.query.get(execution_id) execution = Execution.query.get(execution_id)
if execution: if execution:
@@ -301,74 +366,130 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.error_message = f"Erreur fatale: {str(e)}" execution.error_message = f"Erreur fatale: {str(e)}"
execution.ended_at = datetime.utcnow() execution.ended_at = datetime.utcnow()
db.session.commit() db.session.commit()
except: except Exception as db_err:
pass print(f"⚠️ [Execute] DB cleanup error: {db_err}")
finally: finally:
_execution_state['is_running'] = False with _execution_lock:
_execution_state['current_execution_id'] = None _execution_state['is_running'] = False
_execution_state['current_execution_id'] = None
def execute_ai_analyze(params: dict) -> dict: def execute_ai_analyze(params: dict) -> dict:
""" """
Exécute une analyse IA avec Ollama. Exécute une analyse IA avec Ollama.
Capture la zone de l'ancre et envoie à l'IA pour analyse. Deux modes :
- Mode texte : envoie le texte brut directement (meilleure qualité)
- Mode image : capture la zone de l'ancre et envoie le screenshot
""" """
import requests import requests
import re
global _execution_state
try: try:
# Récupérer les paramètres
anchor = params.get('visual_anchor', {})
prompt = params.get('analysis_prompt', params.get('prompt', '')) prompt = params.get('analysis_prompt', params.get('prompt', ''))
model = params.get('model', params.get('ollama_model', 'qwen2.5-vl:7b')) model = params.get('model', params.get('ollama_model', 'qwen3-vl:8b'))
output_variable = params.get('output_variable', 'resultat_analyse') output_variable = params.get('output_variable', 'resultat_analyse')
timeout_ms = params.get('timeout_ms', 60000) timeout_ms = params.get('timeout_ms', 120000) # 2 minutes par défaut
temperature = params.get('temperature', 0.3) temperature = params.get('temperature', 0.7) # Même défaut que CLI Ollama
max_tokens = params.get('max_tokens', -1) # -1 = illimité (défaut Ollama)
input_text = params.get('input_text', '')
# Récupérer l'image de l'ancre # Résoudre les variables {{var}} dans input_text
screenshot_base64 = anchor.get('screenshot') variables = _execution_state.get('variables', {})
if input_text and '{{' in input_text:
def replace_var(match):
var_name = match.group(1)
value = variables.get(var_name, match.group(0))
print(f" 📌 Variable {{{{{var_name}}}}}{str(value)[:50]}...")
return str(value)
input_text = re.sub(r'\{\{(\w+)\}\}', replace_var, input_text)
if not screenshot_base64: # Déterminer le mode : texte ou image
# Capturer l'écran si pas d'image dans l'ancre use_text_mode = bool(input_text)
try: anchor = params.get('visual_anchor', {})
from PIL import ImageGrab
import io
bbox = anchor.get('bounding_box', {}) print(f"🤖 [IA] Mode: {'TEXTE' if use_text_mode else 'IMAGE'}")
if bbox:
# Capturer la zone spécifique
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else:
# Capturer tout l'écran
screenshot = ImageGrab.grab()
buffer = io.BytesIO() if use_text_mode:
screenshot.save(buffer, format='PNG') # ═══ MODE TEXTE : envoyer le texte directement (comme en CLI) ═══
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') print(f"📝 [IA] Texte brut: {len(input_text)} caractères")
except Exception as cap_err:
return {'success': False, 'error': f"Erreur capture: {cap_err}"}
if not prompt: # Construire le prompt complet avec le texte en entrée
prompt = "Décris ce que tu vois dans cette image." if prompt:
full_prompt = f"{prompt}\n\nVoici le texte :\n{input_text}"
else:
full_prompt = input_text
print(f"🤖 [IA] Analyse avec {model}...") # Pour les modèles Qwen, désactiver le thinking étendu
print(f" Prompt: {prompt[:80]}...") if 'qwen' in model.lower() and not full_prompt.startswith('/no_think'):
full_prompt = f"/no_think\n{full_prompt}"
# Appeler Ollama print(f"🤖 [IA] Analyse texte avec {model}...")
ollama_url = params.get('ollama_url', 'http://localhost:11434')
payload = { ollama_url = params.get('ollama_url', 'http://localhost:11434')
"model": model, options = {"temperature": temperature}
"prompt": prompt, if max_tokens > 0:
"images": [screenshot_base64], options["num_predict"] = max_tokens
"stream": False, payload = {
"options": { "model": model,
"temperature": temperature, "prompt": full_prompt,
"num_predict": 1000 "stream": False,
"options": options
} }
}
else:
# ═══ MODE IMAGE : capturer l'écran et envoyer le screenshot ═══
screenshot_base64 = anchor.get('screenshot') if anchor else None
if not screenshot_base64:
try:
from PIL import ImageGrab
import io
bbox = anchor.get('bounding_box', {}) if anchor else {}
if bbox:
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
print(f"📸 [IA] Capture zone: ({x}, {y}) -> ({x+w}, {y+h})")
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else:
print(f"📸 [IA] Capture écran complet")
screenshot = ImageGrab.grab()
buffer = io.BytesIO()
screenshot.save(buffer, format='PNG')
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
except Exception as cap_err:
return {'success': False, 'error': f"Erreur capture: {cap_err}"}
if not screenshot_base64:
return {'success': False, 'error': "Pas d'image à analyser (ni ancre, ni capture)"}
if not prompt:
prompt = "Décris ce que tu vois dans cette image."
full_prompt = prompt
if 'qwen' in model.lower() and not full_prompt.startswith('/no_think'):
full_prompt = f"/no_think\n{full_prompt}"
print(f"🤖 [IA] Analyse image avec {model}...")
ollama_url = params.get('ollama_url', 'http://localhost:11434')
options = {"temperature": temperature}
if max_tokens > 0:
options["num_predict"] = max_tokens
payload = {
"model": model,
"prompt": full_prompt,
"images": [screenshot_base64],
"stream": False,
"options": options
}
# ═══ APPEL OLLAMA ═══
print(f" Prompt: {full_prompt[:100]}...")
response = requests.post( response = requests.post(
f"{ollama_url}/api/generate", f"{ollama_url}/api/generate",
@@ -380,13 +501,15 @@ def execute_ai_analyze(params: dict) -> dict:
result = response.json() result = response.json()
analysis_text = result.get('response', '').strip() analysis_text = result.get('response', '').strip()
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)") # Fallback : extraire du champ thinking si response vide
print(f" Résultat: {analysis_text[:150]}...") if not analysis_text and result.get('thinking'):
analysis_text = result.get('thinking', '').strip()
# Stocker le résultat dans le contexte d'exécution pour les variables print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
global _execution_state if analysis_text:
if 'variables' not in _execution_state: print(f" Résultat: {analysis_text[:150]}...")
_execution_state['variables'] = {}
# Stocker dans les variables d'exécution
_execution_state['variables'][output_variable] = analysis_text _execution_state['variables'][output_variable] = analysis_text
return { return {
@@ -394,7 +517,8 @@ def execute_ai_analyze(params: dict) -> dict:
'output': { 'output': {
'analysis': analysis_text, 'analysis': analysis_text,
'variable': output_variable, 'variable': output_variable,
'model': model 'model': model,
'mode': 'text' if use_text_mode else 'image'
} }
} }
else: else:
@@ -582,27 +706,34 @@ def execute_action(action_type: str, params: dict) -> dict:
} }
except Exception as vision_err: except Exception as vision_err:
print(f" [Vision] Erreur: {vision_err}") print(f"⚠️ [Vision] Erreur: {vision_err}")
return { if execution_mode in ['intelligent', 'debug']:
'success': False, # En mode visuel, on NE fait PAS de fallback statique
'error': f"Erreur vision: {str(vision_err)}" return {
} 'success': False,
'error': f"Erreur vision: {vision_err}. Ancre introuvable à l'écran."
}
else:
print(f"🔄 [Fallback] Mode basic: utilisation des coordonnées statiques...")
# === MODE BASIC (ou fallback) === # === MODE BASIC uniquement ===
# Calculer le centre depuis les coordonnées statiques if execution_mode not in ['intelligent', 'debug']:
x = bbox.get('x', 0) + bbox.get('width', 0) / 2 x = bbox.get('x', 0) + bbox.get('width', 0) / 2
y = bbox.get('y', 0) + bbox.get('height', 0) / 2 y = bbox.get('y', 0) + bbox.get('height', 0) / 2
print(f"🖱️ [Action] Clic {click_type} à ({x}, {y}) [mode: {execution_mode}]") print(f"🖱️ [Action] Clic {click_type} à ({x}, {y}) [mode: basic, coordonnées statiques]")
if click_type == 'double': if click_type == 'double':
pyautogui.doubleClick(x, y) pyautogui.doubleClick(x, y)
elif click_type == 'right': elif click_type == 'right':
pyautogui.rightClick(x, y) pyautogui.rightClick(x, y)
else: else:
pyautogui.click(x, y) pyautogui.click(x, y)
return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}, 'mode': execution_mode}} return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}, 'mode': 'basic'}}
# En mode intelligent/debug, si on arrive ici c'est que la vision n'a pas été tentée
return {'success': False, 'error': 'Ancre non trouvée (aucune méthode visuelle disponible)'}
elif action_type in ['type_text', 'type']: elif action_type in ['type_text', 'type']:
text = params.get('text', '') text = params.get('text', '')
@@ -631,8 +762,14 @@ def execute_action(action_type: str, params: dict) -> dict:
# Petit délai pour s'assurer que le focus est bon # Petit délai pour s'assurer que le focus est bon
time.sleep(0.2) time.sleep(0.2)
# Utiliser write() pour supporter l'unicode (caractères français, etc.) # Saisie compatible AZERTY/QWERTY (presse-papier > xdotool > pyautogui)
pyautogui.write(text) safe_type_text(text)
# Stocker le texte dans une variable si output_variable est défini
output_variable = params.get('output_variable')
if output_variable:
_execution_state['variables'][output_variable] = text
print(f" 📦 Texte stocké dans variable '{output_variable}' ({len(text)} caractères)")
return {'success': True, 'output': {'typed': text[:100] + '...' if len(text) > 100 else text}} return {'success': True, 'output': {'typed': text[:100] + '...' if len(text) > 100 else text}}
@@ -683,7 +820,7 @@ def start_execution():
data = request.get_json() or {} data = request.get_json() or {}
workflow_id = data.get('workflow_id') workflow_id = data.get('workflow_id')
execution_mode = data.get('execution_mode', 'basic') execution_mode = data.get('execution_mode', 'intelligent')
minimize_browser = data.get('minimize_browser', True) # Activé par défaut minimize_browser = data.get('minimize_browser', True) # Activé par défaut
# Valider le mode # Valider le mode
@@ -727,12 +864,14 @@ def start_execution():
session = get_session_state() session = get_session_state()
session.active_execution_id = execution.id session.active_execution_id = execution.id
# Réinitialiser l'état # Réinitialiser l'état (protégé par lock)
_execution_state['is_running'] = True with _execution_lock:
_execution_state['is_paused'] = False _execution_state['is_running'] = True
_execution_state['should_stop'] = False _execution_state['is_paused'] = False
_execution_state['current_execution_id'] = execution.id _execution_state['should_stop'] = False
_execution_state['execution_mode'] = execution_mode _execution_state['current_execution_id'] = execution.id
_execution_state['execution_mode'] = execution_mode
_execution_state['variables'] = {} # Reset des variables
print(f"🎯 [API v3] Mode d'exécution: {execution_mode}") print(f"🎯 [API v3] Mode d'exécution: {execution_mode}")
@@ -762,7 +901,8 @@ def start_execution():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
_execution_state['is_running'] = False with _execution_lock:
_execution_state['is_running'] = False
return jsonify({ return jsonify({
'success': False, 'success': False,
'error': str(e) 'error': str(e)
@@ -832,14 +972,17 @@ def stop_execution():
"""Arrête l'exécution""" """Arrête l'exécution"""
global _execution_state global _execution_state
if not _execution_state['is_running']: with _execution_lock:
return jsonify({ if not _execution_state['is_running']:
'success': False, return jsonify({
'error': "Aucune exécution en cours" 'success': False,
}), 400 'error': "Aucune exécution en cours"
}), 400
_execution_state['should_stop'] = True _execution_state['should_stop'] = True
_execution_state['is_paused'] = False _execution_state['is_paused'] = False
# Débloquer le thread s'il attend un choix self-healing
_healing_event.set()
print(f"⛔ [API v3] Arrêt demandé") print(f"⛔ [API v3] Arrêt demandé")
@@ -876,6 +1019,8 @@ def get_execution_status():
'execution_mode': _execution_state.get('execution_mode', 'basic'), 'execution_mode': _execution_state.get('execution_mode', 'basic'),
'execution': execution.to_dict() if execution else None, 'execution': execution.to_dict() if execution else None,
'session': session.to_dict(), 'session': session.to_dict(),
# Variables runtime du workflow
'variables': _execution_state.get('variables', {}),
# Self-healing interactif # Self-healing interactif
'waiting_for_choice': _execution_state.get('waiting_for_choice', False), 'waiting_for_choice': _execution_state.get('waiting_for_choice', False),
'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [], 'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [],
@@ -975,9 +1120,11 @@ def submit_healing_choice():
'error': "Coordonnées invalides. Format attendu: {x: number, y: number}" 'error': "Coordonnées invalides. Format attendu: {x: number, y: number}"
}), 400 }), 400
# Stocker le choix # Stocker le choix et réveiller le thread d'exécution
_execution_state['user_choice'] = choice with _execution_lock:
_execution_state['waiting_for_choice'] = False _execution_state['user_choice'] = choice
_execution_state['waiting_for_choice'] = False
_healing_event.set()
print(f"✅ [Self-Healing] Choix reçu: {choice}") print(f"✅ [Self-Healing] Choix reçu: {choice}")

View File

@@ -12,6 +12,8 @@ from flask_socketio import SocketIO
from flask_caching import Cache from flask_caching import Cache
from flask_migrate import Migrate from flask_migrate import Migrate
import os import os
import logging
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables # Load environment variables
@@ -20,6 +22,25 @@ load_dotenv()
# Initialize Flask app # Initialize Flask app
app = Flask(__name__) app = Flask(__name__)
# ============================================================
# Logging — fichier rotatif + console
# ============================================================
_log_dir = os.path.join(os.path.dirname(__file__), 'logs')
os.makedirs(_log_dir, exist_ok=True)
_file_handler = RotatingFileHandler(
os.path.join(_log_dir, 'vwb.log'),
maxBytes=5 * 1024 * 1024, # 5 MB
backupCount=3
)
_file_handler.setLevel(logging.INFO)
_file_handler.setFormatter(logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s'
))
logging.getLogger().addHandler(_file_handler)
logging.getLogger().setLevel(logging.INFO)
# Configuration # Configuration
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vwb_v3.db') app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vwb_v3.db')
@@ -134,18 +155,11 @@ except ImportError as e:
# Catalogue VWB - actions VisionOnly # Catalogue VWB - actions VisionOnly
# V2 avec VLM (Vision Language Model) pour détection intelligente # V2 avec VLM (Vision Language Model) pour détection intelligente
try: try:
from catalog_routes_v2_vlm import catalog_bp from catalog_routes_v2_vlm import catalog_bp, VLM_MODEL
app.register_blueprint(catalog_bp) app.register_blueprint(catalog_bp)
print("✅ Blueprint catalog V2 VLM (Ollama qwen2.5vl) enregistré") print(f"✅ Blueprint catalog V2 VLM (Ollama {VLM_MODEL}) enregistré")
except ImportError as e: except ImportError as e:
print(f"⚠️ Blueprint catalog V2 VLM désactivé: {e}") print(f"⚠️ Blueprint catalog V2 VLM désactivé: {e}")
# Fallback sur la version pyautogui
try:
from catalog_routes import catalog_bp
app.register_blueprint(catalog_bp)
print("✅ Blueprint catalog (fallback pyautogui) enregistré")
except ImportError as e2:
print(f"⚠️ Blueprint catalog désactivé: {e2}")
# API Images Ancres Visuelles - stockage serveur # API Images Ancres Visuelles - stockage serveur
try: try:
@@ -245,13 +259,13 @@ def execute_workflow_step():
'parameters': parameters 'parameters': parameters
} }
# Call the internal catalog execute endpoint # Call the internal catalog execute endpoint (v2 VLM)
from catalog_routes import catalog_bp from catalog_routes_v2_vlm import catalog_bp
# Direct execution via catalog # Direct execution via catalog
try: try:
# Import the execute function directly # Import the execute function directly
from catalog_routes import execute_action as catalog_execute from catalog_routes_v2_vlm import execute_action as catalog_execute
# We need to simulate Flask request context - use internal call # We need to simulate Flask request context - use internal call
from flask import current_app from flask import current_app
with current_app.test_request_context( with current_app.test_request_context(

View File

@@ -50,7 +50,7 @@ except ImportError as e:
# Import des routes du catalogue VWB # Import des routes du catalogue VWB
try: try:
from catalog_routes import register_catalog_routes from catalog_routes_v2_vlm import register_catalog_routes
CATALOG_ROUTES_AVAILABLE = True CATALOG_ROUTES_AVAILABLE = True
print("✅ Routes du catalogue VWB disponibles") print("✅ Routes du catalogue VWB disponibles")
except ImportError as e: except ImportError as e:

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,12 @@ import traceback
# Import des actions et contrats VWB # Import des actions et contrats VWB
try: try:
from visual_workflow_builder.backend.actions import ( from visual_workflow_builder.backend.actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus, BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction, VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction, VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction VWBExtractTextAction
) )
from visual_workflow_builder.backend.contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor
from visual_workflow_builder.backend.contracts.evidence import VWBEvidenceType
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
ACTIONS_AVAILABLE = True ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec succès") print("✅ Actions VWB importées avec succès")
except ImportError as e: except ImportError as e:
@@ -40,14 +38,12 @@ except ImportError as e:
try: try:
# Essayer import relatif # Essayer import relatif
from .actions import ( from .actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus, BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction, VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction, VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction VWBExtractTextAction
) )
from .contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error from .contracts.visual_anchor import VWBVisualAnchor
from .contracts.evidence import VWBEvidenceType
from .contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
ACTIONS_AVAILABLE = True ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec import relatif") print("✅ Actions VWB importées avec import relatif")
except ImportError as e2: except ImportError as e2:
@@ -97,7 +93,7 @@ try:
sys.path.insert(0, '/home/dom/ai/rpa_vision_v3') sys.path.insert(0, '/home/dom/ai/rpa_vision_v3')
if '/home/dom/ai/OmniParser' not in sys.path: if '/home/dom/ai/OmniParser' not in sys.path:
sys.path.insert(0, '/home/dom/ai/OmniParser') sys.path.insert(0, '/home/dom/ai/OmniParser')
from core.detection.omniparser_adapter import get_omniparser, find_element as omniparser_find from core.detection.omniparser_adapter import get_omniparser
omniparser_adapter = get_omniparser() omniparser_adapter = get_omniparser()
OMNIPARSER_AVAILABLE = omniparser_adapter.available OMNIPARSER_AVAILABLE = omniparser_adapter.available
print(f"✅ OmniParser disponible: {OMNIPARSER_AVAILABLE}") print(f"✅ OmniParser disponible: {OMNIPARSER_AVAILABLE}")
@@ -108,19 +104,18 @@ except Exception as e:
OMNIPARSER_AVAILABLE = False OMNIPARSER_AVAILABLE = False
# ============================================================================ # ============================================================================
# VLM (Vision Language Model) - Ollama qwen2.5vl (fallback si OmniParser échoue) # VLM (Vision Language Model) - Ollama (fallback si OmniParser échoue)
# Configurable via variable d'environnement VLM_MODEL
# ============================================================================ # ============================================================================
OLLAMA_URL = "http://localhost:11434" OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
VLM_MODEL = "qwen2.5vl:7b" # Modèle vision local - bon équilibre précision/vitesse VLM_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b") # qwen3-vl offre une meilleure qualité OCR
# ============================================================================ # ============================================================================
# Pipeline VLM Coarse → Refine → Refine++ (Template Matching) # Pipeline VLM Coarse → Refine → Refine++ (Template Matching)
# ============================================================================ # ============================================================================
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal, Optional, List
from dataclasses import dataclass
# Ollama est optionnel - le template matching fonctionnera sans # Ollama est optionnel - le template matching fonctionnera sans
try: try:

View File

@@ -18,8 +18,12 @@ from dataclasses import dataclass
import numpy as np import numpy as np
from PIL import Image from PIL import Image
# Configuration # Configuration — chemin relatif à la racine du projet, ou variable d'environnement
MODEL_PATH = "/home/dom/ai/rpa_vision_v3/models/ui-detr-1/model.pth" _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
MODEL_PATH = os.environ.get(
"UI_DETR_MODEL_PATH",
os.path.join(_PROJECT_ROOT, "models", "ui-detr-1", "model.pth")
)
CONFIDENCE_THRESHOLD = 0.35 CONFIDENCE_THRESHOLD = 0.35
RESOLUTION = 1600 RESOLUTION = 1600