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