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 datetime import datetime
import os
import time
import base64
import io
@@ -26,9 +27,9 @@ from ...contracts.error import VWBErrorType, create_vwb_error
from ...contracts.visual_anchor import VWBVisualAnchor
# Configuration par défaut
OLLAMA_DEFAULT_URL = "http://localhost:11434"
OLLAMA_DEFAULT_MODEL = "qwen2.5-vl:7b"
# Configuration par défaut (centralisée via variable d'environnement)
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen2.5-vl:7b")
class VWBExtraireTableauAction(BaseVWBAction):

View File

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

View File

@@ -16,9 +16,68 @@ import threading
import time
import base64
import os
import logging
import subprocess
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():
"""Minimise la fenêtre active (Linux avec xdotool)"""
@@ -37,7 +96,7 @@ def minimize_active_window():
print(f"⚠️ [Execute] Erreur minimisation: {e}")
return False
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:
@@ -45,6 +104,14 @@ def generate_id(prefix: str) -> str:
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)
_execution_state = {
'is_running': False,
@@ -53,6 +120,7 @@ _execution_state = {
'current_execution_id': None,
'thread': None,
'execution_mode': 'basic', # 'basic', 'intelligent', 'debug'
'variables': {}, # Variables runtime du workflow (initialisé ici, plus de création dynamique)
# Self-healing interactif
'waiting_for_choice': False,
'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)
if not execution or not workflow:
print(f"❌ [Execute] Workflow ou exécution non trouvé")
logger.error("Workflow ou exécution non trouvé")
return
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()
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):
# Vérifier si arrêt demandé
@@ -155,7 +223,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
try:
enforce_action_contract(step.action_type, params)
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.error_message = f"Contrat violé: {str(e)}"
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}")
# Stocker les informations pour le frontend
_execution_state['waiting_for_choice'] = True
_execution_state['pending_action'] = {
'step_id': step.id,
'step_index': index,
'action_type': step.action_type,
'params': params
}
_execution_state['candidates'] = result.get('candidates', [])
_execution_state['current_step_info'] = {
'index': index,
'total': len(steps),
'original_bbox': result.get('original_bbox'),
'error': result.get('error')
}
_execution_state['user_choice'] = None
with _execution_lock:
_healing_event.clear()
_execution_state['waiting_for_choice'] = True
_execution_state['pending_action'] = {
'step_id': step.id,
'step_index': index,
'action_type': step.action_type,
'params': params
}
_execution_state['candidates'] = result.get('candidates', [])
_execution_state['current_step_info'] = {
'index': index,
'total': len(steps),
'original_bbox': result.get('original_bbox'),
'error': result.get('error')
}
_execution_state['user_choice'] = None
# Mettre à jour le status de l'exécution
execution.status = 'waiting_user_choice'
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
waited = 0
while _execution_state['waiting_for_choice'] and waited < timeout_seconds:
if _execution_state['should_stop']:
break
time.sleep(0.5)
waited += 0.5
_healing_event.wait(timeout=timeout_seconds)
# Vérifier si on doit arrêter
if _execution_state['should_stop']:
@@ -211,10 +276,11 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
break
# Traiter le choix de l'utilisateur
user_choice = _execution_state['user_choice']
_execution_state['waiting_for_choice'] = False
_execution_state['pending_action'] = None
_execution_state['candidates'] = []
with _execution_lock:
user_choice = _execution_state['user_choice']
_execution_state['waiting_for_choice'] = False
_execution_state['pending_action'] = None
_execution_state['candidates'] = []
if user_choice is None:
# Timeout - aucun choix
@@ -261,7 +327,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
step_result.status = 'error'
step_result.error_message = result.get('error', 'Erreur inconnue')
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
execution.status = 'error'
@@ -272,7 +338,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
db.session.commit()
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.error_message = str(e)
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()
db.session.commit()
print(f"🏁 [Execute] Workflow terminé: {execution.status}")
print(f" Complétées: {execution.completed_steps}, Échouées: {execution.failed_steps}")
logger.info(f"Workflow terminé: {execution.status} (complétées: {execution.completed_steps}, échouées: {execution.failed_steps})")
except Exception as e:
print(f"❌ [Execute] Erreur fatale: {e}")
logger.error(f"Erreur fatale: {e}", exc_info=True)
try:
execution = Execution.query.get(execution_id)
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.ended_at = datetime.utcnow()
db.session.commit()
except:
pass
except Exception as db_err:
print(f"⚠️ [Execute] DB cleanup error: {db_err}")
finally:
_execution_state['is_running'] = False
_execution_state['current_execution_id'] = None
with _execution_lock:
_execution_state['is_running'] = False
_execution_state['current_execution_id'] = None
def execute_ai_analyze(params: dict) -> dict:
"""
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 re
global _execution_state
try:
# Récupérer les paramètres
anchor = params.get('visual_anchor', {})
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')
timeout_ms = params.get('timeout_ms', 60000)
temperature = params.get('temperature', 0.3)
timeout_ms = params.get('timeout_ms', 120000) # 2 minutes par défaut
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
screenshot_base64 = anchor.get('screenshot')
# Résoudre les variables {{var}} dans input_text
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:
# Capturer l'écran si pas d'image dans l'ancre
try:
from PIL import ImageGrab
import io
# Déterminer le mode : texte ou image
use_text_mode = bool(input_text)
anchor = params.get('visual_anchor', {})
bbox = anchor.get('bounding_box', {})
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()
print(f"🤖 [IA] Mode: {'TEXTE' if use_text_mode else 'IMAGE'}")
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 use_text_mode:
# ═══ MODE TEXTE : envoyer le texte directement (comme en CLI) ═══
print(f"📝 [IA] Texte brut: {len(input_text)} caractères")
if not prompt:
prompt = "Décris ce que tu vois dans cette image."
# Construire le prompt complet avec le texte en entrée
if prompt:
full_prompt = f"{prompt}\n\nVoici le texte :\n{input_text}"
else:
full_prompt = input_text
print(f"🤖 [IA] Analyse avec {model}...")
print(f" Prompt: {prompt[:80]}...")
# Pour les modèles Qwen, désactiver le thinking étendu
if 'qwen' in model.lower() and not full_prompt.startswith('/no_think'):
full_prompt = f"/no_think\n{full_prompt}"
# Appeler Ollama
ollama_url = params.get('ollama_url', 'http://localhost:11434')
print(f"🤖 [IA] Analyse texte avec {model}...")
payload = {
"model": model,
"prompt": prompt,
"images": [screenshot_base64],
"stream": False,
"options": {
"temperature": temperature,
"num_predict": 1000
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,
"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(
f"{ollama_url}/api/generate",
@@ -380,13 +501,15 @@ def execute_ai_analyze(params: dict) -> dict:
result = response.json()
analysis_text = result.get('response', '').strip()
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
print(f" Résultat: {analysis_text[:150]}...")
# Fallback : extraire du champ thinking si response vide
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
global _execution_state
if 'variables' not in _execution_state:
_execution_state['variables'] = {}
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
if analysis_text:
print(f" Résultat: {analysis_text[:150]}...")
# Stocker dans les variables d'exécution
_execution_state['variables'][output_variable] = analysis_text
return {
@@ -394,7 +517,8 @@ def execute_ai_analyze(params: dict) -> dict:
'output': {
'analysis': analysis_text,
'variable': output_variable,
'model': model
'model': model,
'mode': 'text' if use_text_mode else 'image'
}
}
else:
@@ -582,27 +706,34 @@ def execute_action(action_type: str, params: dict) -> dict:
}
except Exception as vision_err:
print(f" [Vision] Erreur: {vision_err}")
return {
'success': False,
'error': f"Erreur vision: {str(vision_err)}"
}
print(f"⚠️ [Vision] Erreur: {vision_err}")
if execution_mode in ['intelligent', 'debug']:
# En mode visuel, on NE fait PAS de fallback statique
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) ===
# Calculer le centre depuis les coordonnées statiques
x = bbox.get('x', 0) + bbox.get('width', 0) / 2
y = bbox.get('y', 0) + bbox.get('height', 0) / 2
# === MODE BASIC uniquement ===
if execution_mode not in ['intelligent', 'debug']:
x = bbox.get('x', 0) + bbox.get('width', 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':
pyautogui.doubleClick(x, y)
elif click_type == 'right':
pyautogui.rightClick(x, y)
else:
pyautogui.click(x, y)
if click_type == 'double':
pyautogui.doubleClick(x, y)
elif click_type == 'right':
pyautogui.rightClick(x, y)
else:
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']:
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
time.sleep(0.2)
# Utiliser write() pour supporter l'unicode (caractères français, etc.)
pyautogui.write(text)
# Saisie compatible AZERTY/QWERTY (presse-papier > xdotool > pyautogui)
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}}
@@ -683,7 +820,7 @@ def start_execution():
data = request.get_json() or {}
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
# Valider le mode
@@ -727,12 +864,14 @@ def start_execution():
session = get_session_state()
session.active_execution_id = execution.id
# Réinitialiser l'état
_execution_state['is_running'] = True
_execution_state['is_paused'] = False
_execution_state['should_stop'] = False
_execution_state['current_execution_id'] = execution.id
_execution_state['execution_mode'] = execution_mode
# Réinitialiser l'état (protégé par lock)
with _execution_lock:
_execution_state['is_running'] = True
_execution_state['is_paused'] = False
_execution_state['should_stop'] = False
_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}")
@@ -762,7 +901,8 @@ def start_execution():
except Exception as e:
db.session.rollback()
_execution_state['is_running'] = False
with _execution_lock:
_execution_state['is_running'] = False
return jsonify({
'success': False,
'error': str(e)
@@ -832,14 +972,17 @@ def stop_execution():
"""Arrête l'exécution"""
global _execution_state
if not _execution_state['is_running']:
return jsonify({
'success': False,
'error': "Aucune exécution en cours"
}), 400
with _execution_lock:
if not _execution_state['is_running']:
return jsonify({
'success': False,
'error': "Aucune exécution en cours"
}), 400
_execution_state['should_stop'] = True
_execution_state['is_paused'] = False
_execution_state['should_stop'] = True
_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é")
@@ -876,6 +1019,8 @@ def get_execution_status():
'execution_mode': _execution_state.get('execution_mode', 'basic'),
'execution': execution.to_dict() if execution else None,
'session': session.to_dict(),
# Variables runtime du workflow
'variables': _execution_state.get('variables', {}),
# Self-healing interactif
'waiting_for_choice': _execution_state.get('waiting_for_choice', False),
'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}"
}), 400
# Stocker le choix
_execution_state['user_choice'] = choice
_execution_state['waiting_for_choice'] = False
# Stocker le choix et réveiller le thread d'exécution
with _execution_lock:
_execution_state['user_choice'] = choice
_execution_state['waiting_for_choice'] = False
_healing_event.set()
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_migrate import Migrate
import os
import logging
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv
# Load environment variables
@@ -20,6 +22,25 @@ load_dotenv()
# Initialize Flask app
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
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')
@@ -134,18 +155,11 @@ except ImportError as e:
# Catalogue VWB - actions VisionOnly
# V2 avec VLM (Vision Language Model) pour détection intelligente
try:
from catalog_routes_v2_vlm import catalog_bp
from catalog_routes_v2_vlm import catalog_bp, VLM_MODEL
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:
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
try:
@@ -245,13 +259,13 @@ def execute_workflow_step():
'parameters': parameters
}
# Call the internal catalog execute endpoint
from catalog_routes import catalog_bp
# Call the internal catalog execute endpoint (v2 VLM)
from catalog_routes_v2_vlm import catalog_bp
# Direct execution via catalog
try:
# 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
from flask import current_app
with current_app.test_request_context(

View File

@@ -50,7 +50,7 @@ except ImportError as e:
# Import des routes du catalogue VWB
try:
from catalog_routes import register_catalog_routes
from catalog_routes_v2_vlm import register_catalog_routes
CATALOG_ROUTES_AVAILABLE = True
print("✅ Routes du catalogue VWB disponibles")
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
try:
from visual_workflow_builder.backend.actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus,
BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction
)
from visual_workflow_builder.backend.contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error
from visual_workflow_builder.backend.contracts.evidence import VWBEvidenceType
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor
ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec succès")
except ImportError as e:
@@ -40,14 +38,12 @@ except ImportError as e:
try:
# Essayer import relatif
from .actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus,
BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction
)
from .contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error
from .contracts.evidence import VWBEvidenceType
from .contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
from .contracts.visual_anchor import VWBVisualAnchor
ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec import relatif")
except ImportError as e2:
@@ -97,7 +93,7 @@ try:
sys.path.insert(0, '/home/dom/ai/rpa_vision_v3')
if '/home/dom/ai/OmniParser' not in sys.path:
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_AVAILABLE = omniparser_adapter.available
print(f"✅ OmniParser disponible: {OMNIPARSER_AVAILABLE}")
@@ -108,19 +104,18 @@ except Exception as e:
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"
VLM_MODEL = "qwen2.5vl:7b" # Modèle vision local - bon équilibre précision/vitesse
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
VLM_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b") # qwen3-vl offre une meilleure qualité OCR
# ============================================================================
# Pipeline VLM Coarse → Refine → Refine++ (Template Matching)
# ============================================================================
from pydantic import BaseModel, Field
from typing import Literal, Optional, List
from dataclasses import dataclass
# Ollama est optionnel - le template matching fonctionnera sans
try:

View File

@@ -18,8 +18,12 @@ from dataclasses import dataclass
import numpy as np
from PIL import Image
# Configuration
MODEL_PATH = "/home/dom/ai/rpa_vision_v3/models/ui-detr-1/model.pth"
# Configuration — chemin relatif à la racine du projet, ou variable d'environnement
_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
RESOLUTION = 1600