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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user