feat: import Excel via chat Léa, suppression nœuds VWB, fix temperature 0.1
- Chat Léa : "importe patients.xlsx" → preview → confirmation → table SQLite Bouton 📎 pour upload fichier, "montre les tables", "info table X" - VWB : suppression nœuds via touche Suppr/Backspace + bouton croix rouge - Fix : toutes les températures VLM à 0.1 (qwen3-vl bloque à 0.0) - Fix : capture VWB avec DISPLAY=:1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,9 @@ from typing import Dict, Any, List, Optional
|
|||||||
|
|
||||||
import requests as http_requests # Pour les appels au streaming server
|
import requests as http_requests # Pour les appels au streaming server
|
||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify
|
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||||
from flask_socketio import SocketIO, emit
|
from flask_socketio import SocketIO, emit
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
@@ -53,6 +54,14 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
GPU_AVAILABLE = False
|
GPU_AVAILABLE = False
|
||||||
|
|
||||||
|
# Import de données Excel/CSV
|
||||||
|
try:
|
||||||
|
from core.data.excel_importer import ExcelImporter
|
||||||
|
from core.data.db_iterator import DBIterator
|
||||||
|
DATA_IMPORT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
DATA_IMPORT_AVAILABLE = False
|
||||||
|
|
||||||
# Execution components (optional - pour exécution réelle)
|
# Execution components (optional - pour exécution réelle)
|
||||||
try:
|
try:
|
||||||
from core.execution import ActionExecutor, TargetResolver, ErrorHandler
|
from core.execution import ActionExecutor, TargetResolver, ErrorHandler
|
||||||
@@ -101,6 +110,18 @@ execution_status = {
|
|||||||
}
|
}
|
||||||
command_history: List[Dict[str, Any]] = []
|
command_history: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Répertoire d'upload et chemin de la base de données
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
UPLOAD_DIR = PROJECT_ROOT / "data" / "uploads"
|
||||||
|
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "databases" / "rpa_data.db"
|
||||||
|
|
||||||
|
# Import de données — composants globaux
|
||||||
|
excel_importer: Optional['ExcelImporter'] = None
|
||||||
|
db_iterator: Optional['DBIterator'] = None
|
||||||
|
|
||||||
|
# État d'import en attente de confirmation (session_id → infos d'import)
|
||||||
|
_pending_imports: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
# Copilot state — suivi du mode pas-à-pas
|
# Copilot state — suivi du mode pas-à-pas
|
||||||
_copilot_sessions: Dict[str, Dict[str, Any]] = {}
|
_copilot_sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
@@ -246,6 +267,21 @@ def init_system():
|
|||||||
logger.warning(f"⚠ GestureCatalog: {e}")
|
logger.warning(f"⚠ GestureCatalog: {e}")
|
||||||
gesture_catalog = None
|
gesture_catalog = None
|
||||||
|
|
||||||
|
# 7. Import de données Excel/CSV
|
||||||
|
global excel_importer, db_iterator
|
||||||
|
if DATA_IMPORT_AVAILABLE:
|
||||||
|
try:
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
excel_importer = ExcelImporter(db_path=str(DEFAULT_DB_PATH))
|
||||||
|
db_iterator = DBIterator(db_path=str(DEFAULT_DB_PATH))
|
||||||
|
logger.info(f"✓ ExcelImporter + DBIterator (db: {DEFAULT_DB_PATH})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠ Data import: {e}")
|
||||||
|
excel_importer = None
|
||||||
|
db_iterator = None
|
||||||
|
else:
|
||||||
|
logger.info("ℹ Import Excel non disponible (openpyxl manquant ?)")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Routes Web
|
# Routes Web
|
||||||
@@ -489,7 +525,13 @@ def api_chat():
|
|||||||
action_taken = None
|
action_taken = None
|
||||||
|
|
||||||
if intent.intent_type == IntentType.CONFIRM:
|
if intent.intent_type == IntentType.CONFIRM:
|
||||||
# Confirmer une action en attente
|
# Vérifier s'il y a un import en attente de confirmation
|
||||||
|
pending_import = _pending_imports.pop(session.session_id, None)
|
||||||
|
if pending_import:
|
||||||
|
result = _execute_pending_import(pending_import)
|
||||||
|
action_taken = "data_import"
|
||||||
|
else:
|
||||||
|
# Confirmer une action en attente (workflow)
|
||||||
pending = conversation_manager.get_pending_confirmation(session)
|
pending = conversation_manager.get_pending_confirmation(session)
|
||||||
if pending:
|
if pending:
|
||||||
confirmation_loop.confirm(pending.id)
|
confirmation_loop.confirm(pending.id)
|
||||||
@@ -505,6 +547,8 @@ def api_chat():
|
|||||||
result = {"confirmed": False}
|
result = {"confirmed": False}
|
||||||
|
|
||||||
elif intent.intent_type == IntentType.DENY:
|
elif intent.intent_type == IntentType.DENY:
|
||||||
|
# Annuler un import en attente
|
||||||
|
_pending_imports.pop(session.session_id, None)
|
||||||
# Refuser une action en attente
|
# Refuser une action en attente
|
||||||
pending = conversation_manager.get_pending_confirmation(session)
|
pending = conversation_manager.get_pending_confirmation(session)
|
||||||
if pending:
|
if pending:
|
||||||
@@ -594,6 +638,11 @@ def api_chat():
|
|||||||
# Niveau 3 : Pas de query exploitable
|
# Niveau 3 : Pas de query exploitable
|
||||||
result = {"not_found": True, "query": query or "", "teach_me": True}
|
result = {"not_found": True, "query": query or "", "teach_me": True}
|
||||||
|
|
||||||
|
elif intent.intent_type == IntentType.DATA_IMPORT:
|
||||||
|
# Import de données Excel/CSV
|
||||||
|
result = _handle_data_import(intent, session.session_id)
|
||||||
|
action_taken = "data_import"
|
||||||
|
|
||||||
elif intent.intent_type == IntentType.LIST:
|
elif intent.intent_type == IntentType.LIST:
|
||||||
# Lister les workflows avec métadonnées enrichies
|
# Lister les workflows avec métadonnées enrichies
|
||||||
if matcher:
|
if matcher:
|
||||||
@@ -669,6 +718,13 @@ def api_chat():
|
|||||||
|
|
||||||
# 6. Générer la réponse (si pas déjà fait pour confirmation)
|
# 6. Générer la réponse (si pas déjà fait pour confirmation)
|
||||||
if action_taken != "confirmation_requested":
|
if action_taken != "confirmation_requested":
|
||||||
|
# Si c'est un import de données (même via CONFIRM/DENY), forcer le handler DATA_IMPORT
|
||||||
|
if action_taken == "data_import":
|
||||||
|
# Créer un intent fictif pour le dispatch vers _handle_data_import
|
||||||
|
from dataclasses import replace as _dc_replace
|
||||||
|
data_intent = _dc_replace(intent, intent_type=IntentType.DATA_IMPORT)
|
||||||
|
response = response_generator.generate(data_intent, context, result)
|
||||||
|
else:
|
||||||
response = response_generator.generate(intent, context, result)
|
response = response_generator.generate(intent, context, result)
|
||||||
|
|
||||||
# 7. Enregistrer le tour dans la conversation
|
# 7. Enregistrer le tour dans la conversation
|
||||||
@@ -893,6 +949,303 @@ def _execute_gesture(gesture):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Import de données Excel/CSV
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _handle_data_import(intent, session_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Traiter une intention DATA_IMPORT.
|
||||||
|
|
||||||
|
Gère les actions : import_file, import_folder, list_tables, table_info.
|
||||||
|
"""
|
||||||
|
if not DATA_IMPORT_AVAILABLE or not excel_importer:
|
||||||
|
return {"error": "L'import Excel n'est pas disponible (openpyxl manquant)."}
|
||||||
|
|
||||||
|
action = intent.parameters.get("action", "import_file")
|
||||||
|
params = intent.parameters
|
||||||
|
|
||||||
|
if action == "list_tables":
|
||||||
|
return _data_list_tables()
|
||||||
|
|
||||||
|
elif action == "table_info":
|
||||||
|
table_name = params.get("table_name")
|
||||||
|
if not table_name:
|
||||||
|
return {"error": "Précisez le nom de la table (ex: 'info table patients')."}
|
||||||
|
return _data_table_info(table_name)
|
||||||
|
|
||||||
|
elif action == "import_folder":
|
||||||
|
folder_path = params.get("folder_path") or params.get("file_path", "")
|
||||||
|
if not folder_path:
|
||||||
|
return {"error": "Précisez le chemin du dossier."}
|
||||||
|
return _data_import_folder(folder_path)
|
||||||
|
|
||||||
|
elif action == "import_file":
|
||||||
|
file_path = params.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return {"error": "Précisez le fichier à importer (ex: 'importe patients.xlsx')."}
|
||||||
|
table_name = params.get("table_name")
|
||||||
|
return _data_preview_file(file_path, table_name, session_id)
|
||||||
|
|
||||||
|
return {"error": "Action d'import non reconnue."}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_file_path(file_path: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Résoudre un chemin de fichier.
|
||||||
|
|
||||||
|
Cherche dans l'ordre :
|
||||||
|
1. Chemin absolu tel quel
|
||||||
|
2. Relatif au dossier d'upload
|
||||||
|
3. Relatif à la racine du projet
|
||||||
|
"""
|
||||||
|
p = Path(file_path)
|
||||||
|
|
||||||
|
# Chemin absolu
|
||||||
|
if p.is_absolute() and p.exists():
|
||||||
|
return p
|
||||||
|
|
||||||
|
# Relatif au dossier d'upload
|
||||||
|
in_uploads = UPLOAD_DIR / p.name
|
||||||
|
if in_uploads.exists():
|
||||||
|
return in_uploads
|
||||||
|
|
||||||
|
# Relatif à la racine du projet
|
||||||
|
in_project = PROJECT_ROOT / file_path
|
||||||
|
if in_project.exists():
|
||||||
|
return in_project
|
||||||
|
|
||||||
|
# Juste le nom du fichier dans uploads
|
||||||
|
if not p.is_absolute():
|
||||||
|
in_uploads2 = UPLOAD_DIR / file_path
|
||||||
|
if in_uploads2.exists():
|
||||||
|
return in_uploads2
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _data_preview_file(file_path: str, table_name: Optional[str], session_id: str) -> Dict[str, Any]:
|
||||||
|
"""Prévisualiser un fichier Excel avant import."""
|
||||||
|
resolved = _resolve_file_path(file_path)
|
||||||
|
if not resolved:
|
||||||
|
return {"file_not_found": True, "file_path": file_path}
|
||||||
|
|
||||||
|
try:
|
||||||
|
preview = excel_importer.preview(str(resolved))
|
||||||
|
# Déterminer le nom de la table
|
||||||
|
if not table_name:
|
||||||
|
table_name = resolved.stem.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
|
||||||
|
# Stocker l'import en attente pour cette session
|
||||||
|
_pending_imports[session_id] = {
|
||||||
|
"file_path": str(resolved),
|
||||||
|
"table_name": table_name,
|
||||||
|
"sheet_name": preview.sheet_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"preview": {
|
||||||
|
"columns": preview.headers,
|
||||||
|
"total_rows": preview.total_rows,
|
||||||
|
"sheet_name": preview.sheet_name,
|
||||||
|
"detected_types": preview.detected_types,
|
||||||
|
"sample_rows": [
|
||||||
|
[str(v) if v is not None else "" for v in row]
|
||||||
|
for row in preview.rows[:3]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"filename": resolved.name,
|
||||||
|
"table_name": table_name,
|
||||||
|
"needs_confirmation": True,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur prévisualisation {file_path}: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_pending_import(pending: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Exécuter un import après confirmation utilisateur."""
|
||||||
|
if not excel_importer:
|
||||||
|
return {"error": "ExcelImporter non disponible."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = excel_importer.import_file(
|
||||||
|
excel_path=pending["file_path"],
|
||||||
|
table_name=pending.get("table_name"),
|
||||||
|
sheet_name=pending.get("sheet_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return {
|
||||||
|
"imported": {
|
||||||
|
"table_name": result.table_name,
|
||||||
|
"row_count": result.row_count,
|
||||||
|
"column_count": result.column_count,
|
||||||
|
"columns": result.columns,
|
||||||
|
"sheet_name": result.sheet_name,
|
||||||
|
"db_path": result.db_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"error": "; ".join(result.errors) or "Import échoué (0 lignes)."}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur import: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_list_tables() -> Dict[str, Any]:
|
||||||
|
"""Lister les tables disponibles dans la base."""
|
||||||
|
if not db_iterator:
|
||||||
|
return {"tables_list": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
tables = db_iterator.list_tables()
|
||||||
|
tables_info = []
|
||||||
|
for t in tables:
|
||||||
|
try:
|
||||||
|
count = db_iterator.count(t)
|
||||||
|
except Exception:
|
||||||
|
count = -1
|
||||||
|
tables_info.append({"name": t, "row_count": count})
|
||||||
|
|
||||||
|
return {"tables_list": tables_info}
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Base pas encore créée — aucune table
|
||||||
|
return {"tables_list": []}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur list_tables: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_table_info(table_name: str) -> Dict[str, Any]:
|
||||||
|
"""Récupérer les infos d'une table."""
|
||||||
|
if not db_iterator:
|
||||||
|
return {"error": "DBIterator non disponible."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
columns = db_iterator.get_columns(table_name)
|
||||||
|
count = db_iterator.count(table_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"table_info": {
|
||||||
|
"table_name": table_name,
|
||||||
|
"columns": columns,
|
||||||
|
"row_count": count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"error": f"Aucune base de données trouvée."}
|
||||||
|
except Exception as e:
|
||||||
|
# Table n'existe probablement pas
|
||||||
|
return {"error": f"Table '{table_name}' introuvable : {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_import_folder(folder_path: str) -> Dict[str, Any]:
|
||||||
|
"""Lister les fichiers Excel dans un dossier."""
|
||||||
|
p = Path(folder_path)
|
||||||
|
if not p.exists() or not p.is_dir():
|
||||||
|
return {"folder_files": [], "folder": folder_path}
|
||||||
|
|
||||||
|
excel_files = sorted(
|
||||||
|
[f.name for f in p.iterdir() if f.suffix.lower() in (".xlsx", ".xls", ".csv")]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folder_files": excel_files,
|
||||||
|
"folder": folder_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/chat/upload', methods=['POST'])
|
||||||
|
def api_chat_upload():
|
||||||
|
"""
|
||||||
|
Upload d'un fichier Excel/CSV via le chat.
|
||||||
|
|
||||||
|
Le fichier est stocké dans data/uploads/ et un aperçu est retourné.
|
||||||
|
"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"error": "Aucun fichier reçu."}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not file.filename:
|
||||||
|
return jsonify({"error": "Nom de fichier vide."}), 400
|
||||||
|
|
||||||
|
# Vérifier l'extension
|
||||||
|
allowed_ext = {'.xlsx', '.xls', '.csv'}
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in allowed_ext:
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Format '{ext}' non supporté. Formats acceptés : {', '.join(allowed_ext)}"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Sauvegarder le fichier
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
# Ajouter un timestamp pour éviter les collisions
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
safe_name = f"{Path(filename).stem}_{ts}{ext}"
|
||||||
|
save_path = UPLOAD_DIR / safe_name
|
||||||
|
file.save(str(save_path))
|
||||||
|
|
||||||
|
logger.info(f"Fichier uploadé : {save_path}")
|
||||||
|
|
||||||
|
# Retourner les infos pour que le frontend envoie un message d'import
|
||||||
|
session_id = request.form.get('session_id', '')
|
||||||
|
|
||||||
|
# Prévisualiser le fichier
|
||||||
|
if DATA_IMPORT_AVAILABLE and excel_importer and ext in ('.xlsx', '.xls'):
|
||||||
|
try:
|
||||||
|
preview = excel_importer.preview(str(save_path))
|
||||||
|
table_name = Path(filename).stem.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
|
||||||
|
# Stocker l'import en attente
|
||||||
|
_pending_imports[session_id] = {
|
||||||
|
"file_path": str(save_path),
|
||||||
|
"table_name": table_name,
|
||||||
|
"sheet_name": preview.sheet_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
cols_str = ", ".join(preview.headers[:8])
|
||||||
|
if len(preview.headers) > 8:
|
||||||
|
cols_str += f"... (+{len(preview.headers) - 8})"
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"filename": file.filename,
|
||||||
|
"saved_as": safe_name,
|
||||||
|
"path": str(save_path),
|
||||||
|
"preview": {
|
||||||
|
"columns": preview.headers,
|
||||||
|
"total_rows": preview.total_rows,
|
||||||
|
"sheet_name": preview.sheet_name,
|
||||||
|
},
|
||||||
|
"message": (
|
||||||
|
f"Fichier **{file.filename}** recu ! "
|
||||||
|
f"{preview.total_rows} lignes, colonnes : {cols_str}. "
|
||||||
|
f"Je cree la table '{table_name}' ?"
|
||||||
|
),
|
||||||
|
"needs_confirmation": True,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur preview upload: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"filename": file.filename,
|
||||||
|
"saved_as": safe_name,
|
||||||
|
"path": str(save_path),
|
||||||
|
"error": f"Fichier sauvegardé mais erreur de lecture : {e}",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"filename": file.filename,
|
||||||
|
"saved_as": safe_name,
|
||||||
|
"path": str(save_path),
|
||||||
|
"message": f"Fichier **{file.filename}** sauvegardé dans {UPLOAD_DIR}.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/help')
|
@app.route('/api/help')
|
||||||
def api_help():
|
def api_help():
|
||||||
"""Aide et mode d'emploi."""
|
"""Aide et mode d'emploi."""
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class IntentType(Enum):
|
|||||||
CONFIRM = "confirm" # Confirmer une action
|
CONFIRM = "confirm" # Confirmer une action
|
||||||
DENY = "deny" # Refuser une action
|
DENY = "deny" # Refuser une action
|
||||||
CLARIFY = "clarify" # Demander une clarification
|
CLARIFY = "clarify" # Demander une clarification
|
||||||
|
DATA_IMPORT = "data_import" # Importer des données (Excel, CSV)
|
||||||
UNKNOWN = "unknown" # Intention non reconnue
|
UNKNOWN = "unknown" # Intention non reconnue
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +75,21 @@ class IntentParser:
|
|||||||
|
|
||||||
# Patterns pour la détection d'intentions par règles
|
# Patterns pour la détection d'intentions par règles
|
||||||
INTENT_PATTERNS = {
|
INTENT_PATTERNS = {
|
||||||
|
IntentType.DATA_IMPORT: [
|
||||||
|
# Import de fichiers Excel/CSV
|
||||||
|
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.xlsx?)\b",
|
||||||
|
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.csv)\b",
|
||||||
|
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+)?excel\s+(.+)",
|
||||||
|
r"(?:importe|charge|lis|lire)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier\s+|de\s+)(.+)",
|
||||||
|
r"(?:crée?|créer?)\s+une?\s+table\s+(?:à\s+partir\s+d[eu]'?\s*)(.+\.xlsx?)\b",
|
||||||
|
# Lister les tables
|
||||||
|
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?\b",
|
||||||
|
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans\s+la\s+base)",
|
||||||
|
r"liste\s+(?:des?\s+)?tables?\s+(?:de\s+)?(?:la\s+)?(?:base)?",
|
||||||
|
# Infos sur une table
|
||||||
|
r"(?:combien\s+de\s+lignes?\s+(?:dans|pour)\s+(?:la\s+)?table\s+)(\w+)",
|
||||||
|
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table\s+(\w+)",
|
||||||
|
],
|
||||||
IntentType.EXECUTE: [
|
IntentType.EXECUTE: [
|
||||||
# Verbes d'action explicites
|
# Verbes d'action explicites
|
||||||
r"(?:lance|exécute|démarre|fai[st]|run|start|execute)\s+(.+)",
|
r"(?:lance|exécute|démarre|fai[st]|run|start|execute)\s+(.+)",
|
||||||
@@ -210,6 +226,25 @@ class IntentParser:
|
|||||||
# Expressions mathématiques : 5+2, 100*3, 12/4, 7-3, 2.5+3.1
|
# Expressions mathématiques : 5+2, 100*3, 12/4, 7-3, 2.5+3.1
|
||||||
r"(\d+(?:[.,]\d+)?\s*[+\-*/x×÷]\s*\d+(?:[.,]\d+)?)",
|
r"(\d+(?:[.,]\d+)?\s*[+\-*/x×÷]\s*\d+(?:[.,]\d+)?)",
|
||||||
],
|
],
|
||||||
|
"file_path": [
|
||||||
|
# Chemins Windows : C:\data\fichier.xlsx
|
||||||
|
r"([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv))",
|
||||||
|
# Chemins Unix : /data/fichier.xlsx
|
||||||
|
r"(/[^\s,]+\.(?:xlsx?|csv))",
|
||||||
|
# Noms de fichier simples : patients.xlsx
|
||||||
|
r"(?:^|\s)([\w\-\.]+\.(?:xlsx?|csv))(?:\s|$)",
|
||||||
|
],
|
||||||
|
"folder_path": [
|
||||||
|
# Dossiers Windows : C:\data\imports
|
||||||
|
r"(?:dossier|répertoire|dir|directory)\s+([A-Za-z]:\\[^\s,]+)",
|
||||||
|
r"([A-Za-z]:\\[^\s,]+)(?:\s|$)",
|
||||||
|
# Dossiers Unix : /data/imports
|
||||||
|
r"(?:dossier|répertoire|dir|directory)\s+(/[^\s,]+)",
|
||||||
|
],
|
||||||
|
"table_name": [
|
||||||
|
# Noms de table (exclure les mots courants comme "à", "de", "la")
|
||||||
|
r"(?:table|la\s+table)\s+['\"]?(\w{2,})['\"]?",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -294,6 +329,10 @@ class IntentParser:
|
|||||||
# 4. Construire les paramètres depuis les entités
|
# 4. Construire les paramètres depuis les entités
|
||||||
parameters = self._entities_to_parameters(entities)
|
parameters = self._entities_to_parameters(entities)
|
||||||
|
|
||||||
|
# 4b. Enrichir les paramètres DATA_IMPORT avec l'action et le chemin
|
||||||
|
if intent_type == IntentType.DATA_IMPORT:
|
||||||
|
parameters = self._enrich_data_import_params(normalized, query, parameters, entities)
|
||||||
|
|
||||||
# 5. Si le LLM est disponible et la confiance est basse, utiliser le LLM
|
# 5. Si le LLM est disponible et la confiance est basse, utiliser le LLM
|
||||||
if self.use_llm and self.llm_available and rule_confidence < 0.7:
|
if self.use_llm and self.llm_available and rule_confidence < 0.7:
|
||||||
llm_result = self._parse_with_llm(query, context)
|
llm_result = self._parse_with_llm(query, context)
|
||||||
@@ -316,6 +355,82 @@ class IntentParser:
|
|||||||
clarification_question=clarification_question
|
clarification_question=clarification_question
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _enrich_data_import_params(
|
||||||
|
self,
|
||||||
|
normalized: str,
|
||||||
|
raw_query: str,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
entities: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Enrichir les paramètres pour une intention DATA_IMPORT.
|
||||||
|
|
||||||
|
Détermine l'action (import_file, import_folder, list_tables, table_info)
|
||||||
|
et extrait le chemin de fichier / nom de table.
|
||||||
|
"""
|
||||||
|
# Déterminer l'action
|
||||||
|
list_patterns = [
|
||||||
|
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?",
|
||||||
|
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans)",
|
||||||
|
r"liste\s+(?:des?\s+)?tables?",
|
||||||
|
]
|
||||||
|
info_patterns = [
|
||||||
|
r"combien\s+de\s+lignes?",
|
||||||
|
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table",
|
||||||
|
]
|
||||||
|
folder_patterns = [
|
||||||
|
r"(?:feuilles?\s+excel|fichiers?\s+excel)\s+(?:du|de)\s+(?:dossier|répertoire)",
|
||||||
|
r"(?:importe|charge|lis)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier|de\s+)",
|
||||||
|
]
|
||||||
|
|
||||||
|
action = "import_file" # Par défaut
|
||||||
|
|
||||||
|
for pat in list_patterns:
|
||||||
|
if re.search(pat, normalized, re.IGNORECASE):
|
||||||
|
action = "list_tables"
|
||||||
|
break
|
||||||
|
|
||||||
|
if action == "import_file":
|
||||||
|
for pat in info_patterns:
|
||||||
|
if re.search(pat, normalized, re.IGNORECASE):
|
||||||
|
action = "table_info"
|
||||||
|
break
|
||||||
|
|
||||||
|
if action == "import_file":
|
||||||
|
for pat in folder_patterns:
|
||||||
|
if re.search(pat, normalized, re.IGNORECASE):
|
||||||
|
action = "import_folder"
|
||||||
|
break
|
||||||
|
|
||||||
|
parameters["action"] = action
|
||||||
|
|
||||||
|
# Extraire le chemin de fichier depuis les entités
|
||||||
|
for entity in entities:
|
||||||
|
if entity["type"] == "file_path" and "file_path" not in parameters:
|
||||||
|
parameters["file_path"] = entity["value"]
|
||||||
|
elif entity["type"] == "folder_path" and "folder_path" not in parameters:
|
||||||
|
parameters["folder_path"] = entity["value"]
|
||||||
|
elif entity["type"] == "table_name" and "table_name" not in parameters:
|
||||||
|
parameters["table_name"] = entity["value"]
|
||||||
|
|
||||||
|
# Fallback : extraire un chemin de fichier depuis la requête brute
|
||||||
|
if "file_path" not in parameters and action == "import_file":
|
||||||
|
# Chercher un .xlsx/.xls/.csv dans la requête brute (supporte les chemins Windows)
|
||||||
|
fp_match = re.search(
|
||||||
|
r'([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv)|/[^\s,]+\.(?:xlsx?|csv)|[\w\-\.]+\.(?:xlsx?|csv))',
|
||||||
|
raw_query,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if fp_match:
|
||||||
|
parameters["file_path"] = fp_match.group(1)
|
||||||
|
|
||||||
|
# Extraire table_name pour table_info depuis la requête
|
||||||
|
if action == "table_info" and "table_name" not in parameters:
|
||||||
|
tm = re.search(r"table\s+['\"]?(\w+)['\"]?", normalized, re.IGNORECASE)
|
||||||
|
if tm:
|
||||||
|
parameters["table_name"] = tm.group(1)
|
||||||
|
|
||||||
|
return parameters
|
||||||
|
|
||||||
def _normalize_query(self, query: str) -> str:
|
def _normalize_query(self, query: str) -> str:
|
||||||
"""Normaliser une requête pour le matching."""
|
"""Normaliser une requête pour le matching."""
|
||||||
# Convertir en minuscules
|
# Convertir en minuscules
|
||||||
|
|||||||
@@ -180,6 +180,44 @@ class ResponseGenerator:
|
|||||||
"{question}",
|
"{question}",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
IntentType.DATA_IMPORT: {
|
||||||
|
"preview": [
|
||||||
|
"J'ai trouvé le fichier **{filename}** — {total_rows} lignes, colonnes : {columns}. Je l'importe dans la table '{table_name}' ?",
|
||||||
|
"Fichier **{filename}** prêt : {total_rows} lignes avec les colonnes {columns}. On crée la table '{table_name}' ?",
|
||||||
|
],
|
||||||
|
"imported": [
|
||||||
|
"Table **'{table_name}'** créée avec {row_count} lignes et {col_count} colonnes ({columns}). Vous pouvez maintenant utiliser 'Pour chaque ligne' dans un workflow !",
|
||||||
|
"Import réussi ! Table **'{table_name}'** : {row_count} lignes, {col_count} colonnes ({columns}).",
|
||||||
|
],
|
||||||
|
"list_tables": [
|
||||||
|
"Voici les tables disponibles :\n{tables_list}",
|
||||||
|
"Tables dans la base :\n{tables_list}",
|
||||||
|
],
|
||||||
|
"no_tables": [
|
||||||
|
"Aucune table n'a été importée pour l'instant. Envoyez-moi un fichier Excel pour commencer !",
|
||||||
|
"La base est vide. Importez un fichier Excel pour créer votre première table.",
|
||||||
|
],
|
||||||
|
"table_info": [
|
||||||
|
"La table **'{table_name}'** contient {row_count} lignes et {col_count} colonnes :\n{columns_detail}",
|
||||||
|
],
|
||||||
|
"folder_list": [
|
||||||
|
"J'ai trouvé {count} fichiers Excel dans le dossier :\n{files_list}\n\nDites-moi lequel importer !",
|
||||||
|
],
|
||||||
|
"folder_empty": [
|
||||||
|
"Aucun fichier Excel trouvé dans le dossier '{folder}'. Vérifiez le chemin.",
|
||||||
|
],
|
||||||
|
"file_not_found": [
|
||||||
|
"Je n'ai pas trouvé le fichier '{file_path}'. Vérifiez le chemin ou envoyez-le via le bouton 📎.",
|
||||||
|
"Fichier introuvable : '{file_path}'. Vous pouvez aussi glisser un fichier dans le chat.",
|
||||||
|
],
|
||||||
|
"error": [
|
||||||
|
"Erreur lors de l'import : {error}",
|
||||||
|
"L'import a échoué : {error}",
|
||||||
|
],
|
||||||
|
"uploaded": [
|
||||||
|
"Fichier **{filename}** reçu ! Je l'analyse...",
|
||||||
|
],
|
||||||
|
},
|
||||||
IntentType.UNKNOWN: {
|
IntentType.UNKNOWN: {
|
||||||
"default": [
|
"default": [
|
||||||
"Je n'ai pas compris. Pouvez-vous reformuler ?",
|
"Je n'ai pas compris. Pouvez-vous reformuler ?",
|
||||||
@@ -209,6 +247,15 @@ class ResponseGenerator:
|
|||||||
"facturer client X",
|
"facturer client X",
|
||||||
"liste des workflows",
|
"liste des workflows",
|
||||||
"aide"
|
"aide"
|
||||||
|
],
|
||||||
|
"after_import": [
|
||||||
|
"montre les tables",
|
||||||
|
"importer un autre fichier",
|
||||||
|
"aide"
|
||||||
|
],
|
||||||
|
"after_table_list": [
|
||||||
|
"importer un fichier Excel",
|
||||||
|
"aide"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +670,111 @@ class ResponseGenerator:
|
|||||||
action_required=False
|
action_required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _handle_data_import(
|
||||||
|
self,
|
||||||
|
intent: ParsedIntent,
|
||||||
|
context: Dict[str, Any],
|
||||||
|
result: Dict[str, Any]
|
||||||
|
) -> GeneratedResponse:
|
||||||
|
"""Handler pour les imports de données (Excel/CSV)."""
|
||||||
|
templates = self.RESPONSE_TEMPLATES[IntentType.DATA_IMPORT]
|
||||||
|
|
||||||
|
if result.get("file_not_found"):
|
||||||
|
template = random.choice(templates["file_not_found"])
|
||||||
|
message = template.format(file_path=result.get("file_path", "?"))
|
||||||
|
suggestions = ["aide"]
|
||||||
|
|
||||||
|
elif result.get("preview"):
|
||||||
|
# Aperçu avant import
|
||||||
|
template = random.choice(templates["preview"])
|
||||||
|
preview = result["preview"]
|
||||||
|
cols_str = ", ".join(preview.get("columns", [])[:8])
|
||||||
|
if len(preview.get("columns", [])) > 8:
|
||||||
|
cols_str += f"... (+{len(preview['columns']) - 8})"
|
||||||
|
message = template.format(
|
||||||
|
filename=result.get("filename", "?"),
|
||||||
|
total_rows=preview.get("total_rows", 0),
|
||||||
|
columns=cols_str,
|
||||||
|
table_name=result.get("table_name", "?"),
|
||||||
|
)
|
||||||
|
suggestions = ["oui", "non"]
|
||||||
|
|
||||||
|
elif result.get("imported"):
|
||||||
|
# Import réussi
|
||||||
|
template = random.choice(templates["imported"])
|
||||||
|
imp = result["imported"]
|
||||||
|
cols_str = ", ".join(list(imp.get("columns", {}).keys())[:6])
|
||||||
|
if len(imp.get("columns", {})) > 6:
|
||||||
|
cols_str += "..."
|
||||||
|
message = template.format(
|
||||||
|
table_name=imp.get("table_name", "?"),
|
||||||
|
row_count=imp.get("row_count", 0),
|
||||||
|
col_count=imp.get("column_count", 0),
|
||||||
|
columns=cols_str,
|
||||||
|
)
|
||||||
|
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_import"]
|
||||||
|
|
||||||
|
elif result.get("tables_list") is not None:
|
||||||
|
tables = result["tables_list"]
|
||||||
|
if tables:
|
||||||
|
lines = []
|
||||||
|
for t in tables:
|
||||||
|
lines.append(f" **{t['name']}** ({t['row_count']} lignes)")
|
||||||
|
template = random.choice(templates["list_tables"])
|
||||||
|
message = template.format(tables_list="\n".join(lines))
|
||||||
|
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
|
||||||
|
else:
|
||||||
|
message = random.choice(templates["no_tables"])
|
||||||
|
suggestions = ["importer un fichier Excel"]
|
||||||
|
|
||||||
|
elif result.get("table_info"):
|
||||||
|
info = result["table_info"]
|
||||||
|
cols_detail = "\n".join(
|
||||||
|
f" {c['name']} ({c['type']})" for c in info.get("columns", [])
|
||||||
|
if c["name"] not in ("_rowid", "_imported_at")
|
||||||
|
)
|
||||||
|
template = random.choice(templates["table_info"])
|
||||||
|
message = template.format(
|
||||||
|
table_name=info.get("table_name", "?"),
|
||||||
|
row_count=info.get("row_count", 0),
|
||||||
|
col_count=len([c for c in info.get("columns", []) if c["name"] not in ("_rowid", "_imported_at")]),
|
||||||
|
columns_detail=cols_detail,
|
||||||
|
)
|
||||||
|
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
|
||||||
|
|
||||||
|
elif result.get("folder_files") is not None:
|
||||||
|
files = result["folder_files"]
|
||||||
|
if files:
|
||||||
|
files_list = "\n".join(f" {f}" for f in files)
|
||||||
|
template = random.choice(templates["folder_list"])
|
||||||
|
message = template.format(count=len(files), files_list=files_list)
|
||||||
|
else:
|
||||||
|
template = random.choice(templates["folder_empty"])
|
||||||
|
message = template.format(folder=result.get("folder", "?"))
|
||||||
|
suggestions = ["aide"]
|
||||||
|
|
||||||
|
elif result.get("uploaded"):
|
||||||
|
template = random.choice(templates["uploaded"])
|
||||||
|
message = template.format(filename=result.get("filename", "?"))
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
elif result.get("error"):
|
||||||
|
template = random.choice(templates["error"])
|
||||||
|
message = template.format(error=result["error"])
|
||||||
|
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_error"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
message = "Je n'ai pas compris la demande d'import. Précisez le fichier ou dites 'montre les tables'."
|
||||||
|
suggestions = ["montre les tables", "aide"]
|
||||||
|
|
||||||
|
return GeneratedResponse(
|
||||||
|
message=message,
|
||||||
|
suggestions=suggestions,
|
||||||
|
action_required=result.get("needs_confirmation", False),
|
||||||
|
action_type="data_import_confirm" if result.get("needs_confirmation") else None,
|
||||||
|
metadata=result,
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_unknown(
|
def _handle_unknown(
|
||||||
self,
|
self,
|
||||||
intent: ParsedIntent,
|
intent: ParsedIntent,
|
||||||
|
|||||||
@@ -447,6 +447,26 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attach-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg-message-bot);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -650,6 +670,10 @@
|
|||||||
<div class="welcome-suggestion-title">📋 Voir les workflows</div>
|
<div class="welcome-suggestion-title">📋 Voir les workflows</div>
|
||||||
<div class="welcome-suggestion-desc">Lister les workflows disponibles</div>
|
<div class="welcome-suggestion-desc">Lister les workflows disponibles</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="welcome-suggestion" onclick="sendSuggestion('Montre-moi les tables')">
|
||||||
|
<div class="welcome-suggestion-title">📊 Importer des données</div>
|
||||||
|
<div class="welcome-suggestion-desc">Importer un fichier Excel ou voir les tables existantes</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -657,6 +681,10 @@
|
|||||||
<!-- Input Area -->
|
<!-- Input Area -->
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
|
<button class="attach-btn" onclick="document.getElementById('fileInput').click()" title="Joindre un fichier Excel">
|
||||||
|
<i class="bi bi-paperclip"></i>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" style="display:none" onchange="handleFileUpload(event)">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<textarea
|
<textarea
|
||||||
id="messageInput"
|
id="messageInput"
|
||||||
@@ -1041,7 +1069,11 @@
|
|||||||
sessionId = data.session_id;
|
sessionId = data.session_id;
|
||||||
|
|
||||||
// Handle different response types
|
// Handle different response types
|
||||||
if (data.result?.needs_confirmation) {
|
if (data.result?.needs_confirmation && data.result?.preview) {
|
||||||
|
// Import de données — apercu avec demande de confirmation
|
||||||
|
addMessage(data.response.message);
|
||||||
|
addSuggestions(['oui', 'non']);
|
||||||
|
} else if (data.result?.needs_confirmation && data.result?.confirmation) {
|
||||||
pendingConfirmation = data.result.confirmation;
|
pendingConfirmation = data.result.confirmation;
|
||||||
const card = createActionCard(
|
const card = createActionCard(
|
||||||
pendingConfirmation.workflow_name,
|
pendingConfirmation.workflow_name,
|
||||||
@@ -1084,6 +1116,18 @@
|
|||||||
msg += `• **${w.name}**: ${w.description || 'Pas de description'}\n`;
|
msg += `• **${w.name}**: ${w.description || 'Pas de description'}\n`;
|
||||||
});
|
});
|
||||||
addMessage(msg);
|
addMessage(msg);
|
||||||
|
} else if (data.result?.imported) {
|
||||||
|
// Import de données réussi
|
||||||
|
addMessage(data.response.message);
|
||||||
|
if (data.response.suggestions?.length > 0) {
|
||||||
|
addSuggestions(data.response.suggestions);
|
||||||
|
}
|
||||||
|
} else if (data.result?.tables_list !== undefined || data.result?.table_info) {
|
||||||
|
// Liste des tables ou info table
|
||||||
|
addMessage(data.response.message);
|
||||||
|
if (data.response.suggestions?.length > 0) {
|
||||||
|
addSuggestions(data.response.suggestions);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addMessage(data.response.message);
|
addMessage(data.response.message);
|
||||||
}
|
}
|
||||||
@@ -1125,6 +1169,53 @@
|
|||||||
addMessage("Demande d'annulation envoyée...");
|
addMessage("Demande d'annulation envoyée...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// File Upload
|
||||||
|
// =====================================================
|
||||||
|
async function handleFileUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Afficher le message utilisateur
|
||||||
|
addMessage(`📎 ${file.name}`, 'user');
|
||||||
|
addTypingIndicator();
|
||||||
|
isProcessing = true;
|
||||||
|
updateInputState();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('session_id', sessionId || '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chat/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
removeTypingIndicator();
|
||||||
|
|
||||||
|
if (data.error && !data.success) {
|
||||||
|
addMessage(`Erreur : ${data.error}`);
|
||||||
|
} else if (data.message) {
|
||||||
|
addMessage(data.message);
|
||||||
|
if (data.needs_confirmation) {
|
||||||
|
addSuggestions(['oui', 'non']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addMessage(`Fichier ${file.name} recu.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
removeTypingIndicator();
|
||||||
|
addMessage(`Erreur d'upload : ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = false;
|
||||||
|
updateInputState();
|
||||||
|
// Reset le champ fichier pour permettre de re-uploader le meme fichier
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// Copilot Mode
|
// Copilot Mode
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ class OllamaClient:
|
|||||||
max_tokens: int = 500,
|
max_tokens: int = 500,
|
||||||
force_json: bool = False) -> Dict[str, Any]:
|
force_json: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Générer une réponse du VLM
|
Générer une réponse du VLM via l'API chat d'Ollama.
|
||||||
|
|
||||||
|
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl
|
||||||
|
avec /api/generate consomme tous les tokens en thinking interne
|
||||||
|
et retourne une réponse vide. L'API chat gère correctement
|
||||||
|
le mode /no_think et sépare thinking/réponse.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: Prompt textuel
|
prompt: Prompt textuel
|
||||||
@@ -74,6 +79,7 @@ class OllamaClient:
|
|||||||
system_prompt: Prompt système (optionnel)
|
system_prompt: Prompt système (optionnel)
|
||||||
temperature: Température de génération
|
temperature: Température de génération
|
||||||
max_tokens: Nombre max de tokens
|
max_tokens: Nombre max de tokens
|
||||||
|
force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict avec 'response', 'success', 'error'
|
Dict avec 'response', 'success', 'error'
|
||||||
@@ -86,45 +92,51 @@ class OllamaClient:
|
|||||||
elif image:
|
elif image:
|
||||||
image_data = self._encode_image_from_pil(image)
|
image_data = self._encode_image_from_pil(image)
|
||||||
|
|
||||||
# Construire la requête avec thinking mode désactivé
|
# Construire le prompt avec /no_think pour désactiver le thinking
|
||||||
# Pour Qwen3, utiliser /nothink au début du prompt
|
|
||||||
effective_prompt = prompt
|
effective_prompt = prompt
|
||||||
if "qwen" in self.model.lower():
|
if "qwen" in self.model.lower():
|
||||||
effective_prompt = f"/nothink {prompt}"
|
# S'assurer que /no_think est présent (pas de doublon)
|
||||||
|
if "/no_think" not in prompt and "/nothink" not in prompt:
|
||||||
|
effective_prompt = f"/no_think\n{prompt}"
|
||||||
|
|
||||||
|
# Construire le message utilisateur
|
||||||
|
user_message = {"role": "user", "content": effective_prompt}
|
||||||
|
if image_data:
|
||||||
|
user_message["images"] = [image_data]
|
||||||
|
|
||||||
|
# Construire les messages
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append(user_message)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"prompt": effective_prompt,
|
"messages": messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"num_predict": max_tokens,
|
"num_predict": max_tokens,
|
||||||
"num_ctx": 2048, # Contexte réduit pour plus de vitesse
|
"num_ctx": 2048,
|
||||||
"top_k": 1 # Plus rapide pour les tâches de classification
|
"top_k": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Forcer la sortie JSON si demandé (réduit drastiquement les erreurs de parsing)
|
|
||||||
if force_json:
|
if force_json:
|
||||||
payload["format"] = "json"
|
payload["format"] = "json"
|
||||||
|
|
||||||
if system_prompt:
|
# Envoyer la requête via l'API chat
|
||||||
payload["system"] = system_prompt
|
|
||||||
|
|
||||||
if image_data:
|
|
||||||
payload["images"] = [image_data]
|
|
||||||
|
|
||||||
# Envoyer la requête
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.endpoint}/api/generate",
|
f"{self.endpoint}/api/chat",
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=self.timeout
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
return {
|
return {
|
||||||
"response": result.get("response", ""),
|
"response": content,
|
||||||
"success": True,
|
"success": True,
|
||||||
"error": None
|
"error": None
|
||||||
}
|
}
|
||||||
@@ -197,7 +209,7 @@ Respond with just the type name, nothing else."""
|
|||||||
if context:
|
if context:
|
||||||
prompt += f"\n\nContext: {context}"
|
prompt += f"\n\nContext: {context}"
|
||||||
|
|
||||||
result = self.generate(prompt, image=element_image, temperature=0.0)
|
result = self.generate(prompt, image=element_image, temperature=0.1)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
element_type = result["response"].strip().lower()
|
element_type = result["response"].strip().lower()
|
||||||
@@ -238,7 +250,7 @@ Respond with just the role name, nothing else."""
|
|||||||
if context:
|
if context:
|
||||||
prompt += f"\n\nContext: {context}"
|
prompt += f"\n\nContext: {context}"
|
||||||
|
|
||||||
result = self.generate(prompt, image=element_image, temperature=0.0)
|
result = self.generate(prompt, image=element_image, temperature=0.1)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
role = result["response"].strip().lower()
|
role = result["response"].strip().lower()
|
||||||
@@ -266,7 +278,7 @@ Respond with just the role name, nothing else."""
|
|||||||
"""
|
"""
|
||||||
prompt = "Extract all visible text from this image. Return only the text, nothing else."
|
prompt = "Extract all visible text from this image. Return only the text, nothing else."
|
||||||
|
|
||||||
result = self.generate(prompt, image=image, temperature=0.0)
|
result = self.generate(prompt, image=image, temperature=0.1)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
return {"text": result["response"].strip(), "success": True}
|
return {"text": result["response"].strip(), "success": True}
|
||||||
@@ -288,30 +300,26 @@ Respond with just the role name, nothing else."""
|
|||||||
Returns:
|
Returns:
|
||||||
Dict avec 'type', 'role', 'text', 'confidence', 'success'
|
Dict avec 'type', 'role', 'text', 'confidence', 'success'
|
||||||
"""
|
"""
|
||||||
# System prompt direct — pas de thinking, JSON uniquement
|
# Prompt concis sans system prompt — le system prompt avec qwen3-vl
|
||||||
system_prompt = "You are a JSON-only UI classifier. No thinking. No explanation. Output raw JSON only."
|
# augmente considérablement le nombre de tokens de thinking interne,
|
||||||
|
# causant des réponses vides quand le budget tokens est trop bas.
|
||||||
# User prompt avec exemples explicites pour guider le modèle
|
|
||||||
prompt = """/no_think
|
prompt = """/no_think
|
||||||
Look at this UI element image and classify it. Reply with ONLY a JSON object, nothing else.
|
Classify this UI element. Reply with ONLY a JSON object, nothing else.
|
||||||
|
|
||||||
Types: button, text_input, checkbox, radio, dropdown, tab, link, icon, table_row, menu_item
|
Types: button, text_input, checkbox, radio, dropdown, tab, link, icon, table_row, menu_item
|
||||||
Roles: primary_action, cancel, submit, form_input, search_field, navigation, settings, close, delete, edit, save
|
Roles: primary_action, cancel, submit, form_input, search_field, navigation, settings, close, delete, edit, save
|
||||||
|
Example: {"type": "button", "role": "submit", "text": "OK"}
|
||||||
Example 1: {"type": "button", "role": "submit", "text": "OK"}
|
|
||||||
Example 2: {"type": "text_input", "role": "form_input", "text": ""}
|
|
||||||
Example 3: {"type": "icon", "role": "close", "text": "X"}
|
|
||||||
|
|
||||||
Your answer:"""
|
Your answer:"""
|
||||||
|
|
||||||
# Note: force_json=False car qwen3-vl ne supporte pas format:json
|
# Note: force_json=False car qwen3-vl ne supporte pas format:json
|
||||||
# temperature=0.1 car qwen3-vl bloque à 0.0 avec des images
|
# temperature=0.1 car qwen3-vl bloque à 0.0 avec des images
|
||||||
|
# max_tokens=800 car qwen3-vl consomme 300-700 tokens en thinking
|
||||||
|
# interne même avec /no_think — les images complexes nécessitent
|
||||||
|
# plus de budget pour que la réponse JSON visible soit complète
|
||||||
result = self.generate(
|
result = self.generate(
|
||||||
prompt,
|
prompt,
|
||||||
image=element_image,
|
image=element_image,
|
||||||
system_prompt=system_prompt,
|
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=200,
|
max_tokens=800,
|
||||||
force_json=False
|
force_json=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function App() {
|
|||||||
id: step.id,
|
id: step.id,
|
||||||
type: 'step',
|
type: 'step',
|
||||||
position: step.position || { x: 100, y: 100 + index * 120 },
|
position: step.position || { x: 100, y: 100 + index * 120 },
|
||||||
data: { step },
|
data: { step, onDelete: handleDeleteStep },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setNodes(newNodes);
|
setNodes(newNodes);
|
||||||
@@ -475,10 +475,13 @@ function App() {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onEdgesDelete={onEdgesDelete}
|
||||||
|
onNodesDelete={(deleted) => {
|
||||||
|
deleted.forEach((node) => handleDeleteStep(node.id));
|
||||||
|
}}
|
||||||
onNodeClick={(_, node) => handleSelectStep(node.id)}
|
onNodeClick={(_, node) => handleSelectStep(node.id)}
|
||||||
onNodeDragStop={handleNodeDragStop}
|
onNodeDragStop={handleNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
deleteKeyCode="Delete"
|
deleteKeyCode={['Delete', 'Backspace']}
|
||||||
fitView
|
fitView
|
||||||
>
|
>
|
||||||
<Controls />
|
<Controls />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ACTIONS } from '../types';
|
|||||||
import { getAnchorThumbnailUrl } from '../services/api';
|
import { getAnchorThumbnailUrl } from '../services/api';
|
||||||
|
|
||||||
interface StepNodeProps {
|
interface StepNodeProps {
|
||||||
data: { step: Step };
|
data: { step: Step; onDelete?: (id: string) => void };
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,20 @@ function StepNode({ data, selected }: StepNodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
|
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
|
||||||
|
{/* Bouton supprimer */}
|
||||||
|
{selected && (
|
||||||
|
<button
|
||||||
|
className="step-node-delete"
|
||||||
|
title="Supprimer (Suppr)"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
data.onDelete?.(step.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Entrée: haut */}
|
{/* Entrée: haut */}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
|
|||||||
@@ -415,6 +415,32 @@ body {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-node-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e53935;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid white;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-node-delete:hover {
|
||||||
|
background: #c62828;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-node.selected {
|
.step-node.selected {
|
||||||
|
|||||||
Reference in New Issue
Block a user