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:
Dom
2026-03-17 07:18:51 +01:00
parent 97cb2957d5
commit 928b9e1065
8 changed files with 820 additions and 58 deletions

View File

@@ -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."""