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
|
||||
|
||||
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 werkzeug.utils import secure_filename
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@@ -53,6 +54,14 @@ try:
|
||||
except ImportError:
|
||||
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)
|
||||
try:
|
||||
from core.execution import ActionExecutor, TargetResolver, ErrorHandler
|
||||
@@ -101,6 +110,18 @@ execution_status = {
|
||||
}
|
||||
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_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -246,6 +267,21 @@ def init_system():
|
||||
logger.warning(f"⚠ GestureCatalog: {e}")
|
||||
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
|
||||
@@ -489,22 +525,30 @@ def api_chat():
|
||||
action_taken = None
|
||||
|
||||
if intent.intent_type == IntentType.CONFIRM:
|
||||
# Confirmer une action en attente
|
||||
pending = conversation_manager.get_pending_confirmation(session)
|
||||
if pending:
|
||||
confirmation_loop.confirm(pending.id)
|
||||
conversation_manager.clear_pending_confirmation(session)
|
||||
result = {"confirmed": True, "workflow": pending.workflow_name}
|
||||
action_taken = "confirmed"
|
||||
|
||||
# Lancer l'exécution
|
||||
socketio.start_background_task(
|
||||
execute_workflow_from_confirmation, pending, session.session_id
|
||||
)
|
||||
# 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:
|
||||
result = {"confirmed": False}
|
||||
# Confirmer une action en attente (workflow)
|
||||
pending = conversation_manager.get_pending_confirmation(session)
|
||||
if pending:
|
||||
confirmation_loop.confirm(pending.id)
|
||||
conversation_manager.clear_pending_confirmation(session)
|
||||
result = {"confirmed": True, "workflow": pending.workflow_name}
|
||||
action_taken = "confirmed"
|
||||
|
||||
# Lancer l'exécution
|
||||
socketio.start_background_task(
|
||||
execute_workflow_from_confirmation, pending, session.session_id
|
||||
)
|
||||
else:
|
||||
result = {"confirmed": False}
|
||||
|
||||
elif intent.intent_type == IntentType.DENY:
|
||||
# Annuler un import en attente
|
||||
_pending_imports.pop(session.session_id, None)
|
||||
# Refuser une action en attente
|
||||
pending = conversation_manager.get_pending_confirmation(session)
|
||||
if pending:
|
||||
@@ -594,6 +638,11 @@ def api_chat():
|
||||
# Niveau 3 : Pas de query exploitable
|
||||
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:
|
||||
# Lister les workflows avec métadonnées enrichies
|
||||
if matcher:
|
||||
@@ -669,7 +718,14 @@ def api_chat():
|
||||
|
||||
# 6. Générer la réponse (si pas déjà fait pour confirmation)
|
||||
if action_taken != "confirmation_requested":
|
||||
response = response_generator.generate(intent, context, result)
|
||||
# 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)
|
||||
|
||||
# 7. Enregistrer le tour dans la conversation
|
||||
conversation_manager.add_turn(
|
||||
@@ -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')
|
||||
def api_help():
|
||||
"""Aide et mode d'emploi."""
|
||||
|
||||
@@ -36,6 +36,7 @@ class IntentType(Enum):
|
||||
CONFIRM = "confirm" # Confirmer une action
|
||||
DENY = "deny" # Refuser une action
|
||||
CLARIFY = "clarify" # Demander une clarification
|
||||
DATA_IMPORT = "data_import" # Importer des données (Excel, CSV)
|
||||
UNKNOWN = "unknown" # Intention non reconnue
|
||||
|
||||
|
||||
@@ -74,6 +75,21 @@ class IntentParser:
|
||||
|
||||
# Patterns pour la détection d'intentions par règles
|
||||
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: [
|
||||
# Verbes d'action explicites
|
||||
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
|
||||
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__(
|
||||
@@ -294,6 +329,10 @@ class IntentParser:
|
||||
# 4. Construire les paramètres depuis les entités
|
||||
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
|
||||
if self.use_llm and self.llm_available and rule_confidence < 0.7:
|
||||
llm_result = self._parse_with_llm(query, context)
|
||||
@@ -316,6 +355,82 @@ class IntentParser:
|
||||
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:
|
||||
"""Normaliser une requête pour le matching."""
|
||||
# Convertir en minuscules
|
||||
|
||||
@@ -180,6 +180,44 @@ class ResponseGenerator:
|
||||
"{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: {
|
||||
"default": [
|
||||
"Je n'ai pas compris. Pouvez-vous reformuler ?",
|
||||
@@ -209,6 +247,15 @@ class ResponseGenerator:
|
||||
"facturer client X",
|
||||
"liste des workflows",
|
||||
"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
|
||||
)
|
||||
|
||||
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(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
|
||||
@@ -447,6 +447,26 @@
|
||||
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 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -650,6 +670,10 @@
|
||||
<div class="welcome-suggestion-title">📋 Voir les workflows</div>
|
||||
<div class="welcome-suggestion-desc">Lister les workflows disponibles</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>
|
||||
@@ -657,6 +681,10 @@
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<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">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
@@ -1041,7 +1069,11 @@
|
||||
sessionId = data.session_id;
|
||||
|
||||
// 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;
|
||||
const card = createActionCard(
|
||||
pendingConfirmation.workflow_name,
|
||||
@@ -1084,6 +1116,18 @@
|
||||
msg += `• **${w.name}**: ${w.description || 'Pas de description'}\n`;
|
||||
});
|
||||
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 {
|
||||
addMessage(data.response.message);
|
||||
}
|
||||
@@ -1125,6 +1169,53 @@
|
||||
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
|
||||
// =====================================================
|
||||
|
||||
Reference in New Issue
Block a user