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 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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +244,8 @@ 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
|
||||||
|
with _execution_lock:
|
||||||
|
_healing_event.clear()
|
||||||
_execution_state['waiting_for_choice'] = True
|
_execution_state['waiting_for_choice'] = True
|
||||||
_execution_state['pending_action'] = {
|
_execution_state['pending_action'] = {
|
||||||
'step_id': step.id,
|
'step_id': step.id,
|
||||||
@@ -196,14 +266,9 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
|
|||||||
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,6 +276,7 @@ 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
|
||||||
|
with _execution_lock:
|
||||||
user_choice = _execution_state['user_choice']
|
user_choice = _execution_state['user_choice']
|
||||||
_execution_state['waiting_for_choice'] = False
|
_execution_state['waiting_for_choice'] = False
|
||||||
_execution_state['pending_action'] = None
|
_execution_state['pending_action'] = None
|
||||||
@@ -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,10 +366,11 @@ 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:
|
||||||
|
with _execution_lock:
|
||||||
_execution_state['is_running'] = False
|
_execution_state['is_running'] = False
|
||||||
_execution_state['current_execution_id'] = None
|
_execution_state['current_execution_id'] = None
|
||||||
|
|
||||||
@@ -312,36 +378,84 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
|
|||||||
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)
|
||||||
|
|
||||||
|
# Déterminer le mode : texte ou image
|
||||||
|
use_text_mode = bool(input_text)
|
||||||
|
anchor = params.get('visual_anchor', {})
|
||||||
|
|
||||||
|
print(f"🤖 [IA] Mode: {'TEXTE' if use_text_mode else 'IMAGE'}")
|
||||||
|
|
||||||
|
if use_text_mode:
|
||||||
|
# ═══ MODE TEXTE : envoyer le texte directement (comme en CLI) ═══
|
||||||
|
print(f"📝 [IA] Texte brut: {len(input_text)} caractères")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
print(f"🤖 [IA] Analyse texte 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,
|
||||||
|
"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:
|
if not screenshot_base64:
|
||||||
# Capturer l'écran si pas d'image dans l'ancre
|
|
||||||
try:
|
try:
|
||||||
from PIL import ImageGrab
|
from PIL import ImageGrab
|
||||||
import io
|
import io
|
||||||
|
|
||||||
bbox = anchor.get('bounding_box', {})
|
bbox = anchor.get('bounding_box', {}) if anchor else {}
|
||||||
|
|
||||||
if bbox:
|
if bbox:
|
||||||
# Capturer la zone spécifique
|
|
||||||
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
|
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
|
||||||
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
|
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))
|
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
|
||||||
else:
|
else:
|
||||||
# Capturer tout l'écran
|
print(f"📸 [IA] Capture écran complet")
|
||||||
screenshot = ImageGrab.grab()
|
screenshot = ImageGrab.grab()
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
@@ -350,26 +464,33 @@ def execute_ai_analyze(params: dict) -> dict:
|
|||||||
except Exception as cap_err:
|
except Exception as cap_err:
|
||||||
return {'success': False, 'error': f"Erreur capture: {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:
|
if not prompt:
|
||||||
prompt = "Décris ce que tu vois dans cette image."
|
prompt = "Décris ce que tu vois dans cette image."
|
||||||
|
|
||||||
print(f"🤖 [IA] Analyse avec {model}...")
|
full_prompt = prompt
|
||||||
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}"
|
||||||
|
|
||||||
|
print(f"🤖 [IA] Analyse image avec {model}...")
|
||||||
|
|
||||||
# Appeler Ollama
|
|
||||||
ollama_url = params.get('ollama_url', 'http://localhost:11434')
|
ollama_url = params.get('ollama_url', 'http://localhost:11434')
|
||||||
|
options = {"temperature": temperature}
|
||||||
|
if max_tokens > 0:
|
||||||
|
options["num_predict"] = max_tokens
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": full_prompt,
|
||||||
"images": [screenshot_base64],
|
"images": [screenshot_base64],
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": options
|
||||||
"temperature": temperature,
|
|
||||||
"num_predict": 1000
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ═══ APPEL OLLAMA ═══
|
||||||
|
print(f" Prompt: {full_prompt[:100]}...")
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{ollama_url}/api/generate",
|
f"{ollama_url}/api/generate",
|
||||||
json=payload,
|
json=payload,
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
# Fallback : extraire du champ thinking si response vide
|
||||||
|
if not analysis_text and result.get('thinking'):
|
||||||
|
analysis_text = result.get('thinking', '').strip()
|
||||||
|
|
||||||
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
|
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
|
||||||
|
if analysis_text:
|
||||||
print(f" Résultat: {analysis_text[:150]}...")
|
print(f" Résultat: {analysis_text[:150]}...")
|
||||||
|
|
||||||
# Stocker le résultat dans le contexte d'exécution pour les variables
|
# Stocker dans les variables d'exécution
|
||||||
global _execution_state
|
|
||||||
if 'variables' not in _execution_state:
|
|
||||||
_execution_state['variables'] = {}
|
|
||||||
_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,18 +706,22 @@ 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}")
|
||||||
|
if execution_mode in ['intelligent', 'debug']:
|
||||||
|
# En mode visuel, on NE fait PAS de fallback statique
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f"Erreur vision: {str(vision_err)}"
|
'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)
|
||||||
@@ -602,7 +730,10 @@ def execute_action(action_type: str, params: dict) -> dict:
|
|||||||
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)
|
||||||
|
with _execution_lock:
|
||||||
_execution_state['is_running'] = True
|
_execution_state['is_running'] = True
|
||||||
_execution_state['is_paused'] = False
|
_execution_state['is_paused'] = False
|
||||||
_execution_state['should_stop'] = False
|
_execution_state['should_stop'] = False
|
||||||
_execution_state['current_execution_id'] = execution.id
|
_execution_state['current_execution_id'] = execution.id
|
||||||
_execution_state['execution_mode'] = execution_mode
|
_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,6 +901,7 @@ def start_execution():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
with _execution_lock:
|
||||||
_execution_state['is_running'] = False
|
_execution_state['is_running'] = False
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
@@ -832,6 +972,7 @@ def stop_execution():
|
|||||||
"""Arrête l'exécution"""
|
"""Arrête l'exécution"""
|
||||||
global _execution_state
|
global _execution_state
|
||||||
|
|
||||||
|
with _execution_lock:
|
||||||
if not _execution_state['is_running']:
|
if not _execution_state['is_running']:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
@@ -840,6 +981,8 @@ def stop_execution():
|
|||||||
|
|
||||||
_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
|
||||||
|
with _execution_lock:
|
||||||
_execution_state['user_choice'] = choice
|
_execution_state['user_choice'] = choice
|
||||||
_execution_state['waiting_for_choice'] = False
|
_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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user