feat: first IA page
This commit is contained in:
267
src/plesna_gerance/services/ollama_service.py
Normal file
267
src/plesna_gerance/services/ollama_service.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Service Ollama avec boucle tool-calling pour l'assistant IA."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from .sql_executor import execute_readonly_sql, get_schema_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration via variables d'environnement
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5")
|
||||
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120"))
|
||||
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
# Définition des tools pour l'API Ollama
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_schema",
|
||||
"description": "Retourne la description complète du schéma de la base de données (tables, colonnes, types, relations).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "execute_sql",
|
||||
"description": "Exécute une requête SQL SELECT read-only sur la base de données et retourne les résultats (colonnes et lignes).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Requête SQL SELECT à exécuter. Seules les requêtes SELECT et WITH sont autorisées.",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_available_charts",
|
||||
"description": "Retourne les types de graphiques disponibles avec des recommandations d'usage. Appelle cet outil pour choisir le type de graphique le plus adapté à la question.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
AVAILABLE_CHARTS = {
|
||||
"available_types": [
|
||||
{"type": "bar", "name": "Barres", "usage": "Comparaisons entre catégories, classements, top N, totaux par groupe"},
|
||||
{"type": "line", "name": "Lignes", "usage": "Évolutions temporelles, tendances, séries chronologiques mensuelles/annuelles"},
|
||||
{"type": "doughnut", "name": "Anneau", "usage": "Répartitions en parts, proportions d'un tout, pourcentages"},
|
||||
{"type": "pie", "name": "Camembert", "usage": "Similaire à anneau, préférer pour moins de 6 catégories"},
|
||||
]
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = f"""Tu es un assistant IA spécialisé dans l'analyse de données de gestion immobilière.
|
||||
Tu as accès à une base de données SQLite via des outils (tools).
|
||||
|
||||
{get_schema_description()}
|
||||
|
||||
Instructions :
|
||||
1. Commence TOUJOURS par appeler get_schema() pour vérifier le schéma actuel de la base.
|
||||
2. Utilise execute_sql() pour exécuter des requêtes SQL et obtenir les données nécessaires.
|
||||
3. Tu peux appeler execute_sql() plusieurs fois si nécessaire pour affiner ta réponse.
|
||||
4. Réponds TOUJOURS en français.
|
||||
5. Ta réponse finale doit être un JSON valide avec cette structure exacte :
|
||||
|
||||
{{
|
||||
"answer": "Explication textuelle de la réponse en français",
|
||||
"data": {{"columns": ["col1", "col2"], "rows": [["val1", "val2"]], "row_count": 1}},
|
||||
"chart_config": {{
|
||||
"type": "bar",
|
||||
"data": {{
|
||||
"labels": ["Label1", "Label2"],
|
||||
"datasets": [{{
|
||||
"label": "Nom du dataset",
|
||||
"data": [10, 20],
|
||||
"backgroundColor": ["rgba(59, 130, 246, 0.8)", "rgba(16, 185, 129, 0.8)"]
|
||||
}}]
|
||||
}},
|
||||
"options": {{
|
||||
"responsive": true,
|
||||
"plugins": {{
|
||||
"title": {{
|
||||
"display": true,
|
||||
"text": "Titre du graphique"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
"sql_query": "SELECT ... FROM ..."
|
||||
}}
|
||||
|
||||
Règles pour chart_config :
|
||||
- Appelle get_available_charts() pour connaître les types de graphiques disponibles et choisir le plus adapté à la question.
|
||||
- Utilise ces couleurs sombres pour les datasets :
|
||||
rgba(59, 130, 246, 0.8) — bleu
|
||||
rgba(16, 185, 129, 0.8) — vert
|
||||
rgba(239, 68, 68, 0.8) — rouge
|
||||
rgba(245, 158, 11, 0.8) — ambre
|
||||
rgba(139, 92, 246, 0.8) — violet
|
||||
rgba(236, 72, 153, 0.8) — rose
|
||||
rgba(6, 182, 212, 0.8) — cyan
|
||||
rgba(249, 115, 22, 0.8) — orange
|
||||
- Si la question ne nécessite pas de graphique, mets chart_config à null.
|
||||
- Si la question ne nécessite pas de données tabulaires, mets data à null.
|
||||
- sql_query contient la dernière requête SQL principale exécutée.
|
||||
|
||||
IMPORTANT : Ta réponse finale (après avoir utilisé les outils) doit être UNIQUEMENT le JSON, sans texte avant ou après, sans bloc markdown."""
|
||||
|
||||
|
||||
def _execute_tool(name: str, arguments: dict) -> str:
|
||||
"""Exécute un tool et retourne le résultat sous forme de string."""
|
||||
if name == "get_schema":
|
||||
return get_schema_description()
|
||||
elif name == "get_available_charts":
|
||||
return json.dumps(AVAILABLE_CHARTS, ensure_ascii=False)
|
||||
elif name == "execute_sql":
|
||||
query = arguments.get("query", "")
|
||||
try:
|
||||
result = execute_readonly_sql(query)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
except (ValueError, Exception) as e:
|
||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
else:
|
||||
return json.dumps({"error": f"Outil inconnu : {name}"})
|
||||
|
||||
|
||||
def _parse_response(content: str) -> dict:
|
||||
"""Parse la réponse JSON du LLM avec fallback en texte brut."""
|
||||
if not content:
|
||||
return {"answer": "Pas de réponse.", "data": None, "chart_config": None, "sql_query": None}
|
||||
|
||||
# Essayer de parser le JSON directement
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Essayer d'extraire un bloc JSON du texte
|
||||
# Chercher entre ```json ... ``` ou entre { ... }
|
||||
import re
|
||||
|
||||
# Bloc markdown
|
||||
md_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
|
||||
if md_match:
|
||||
try:
|
||||
return json.loads(md_match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Dernier objet JSON dans le texte
|
||||
brace_match = re.search(r"\{.*\}", content, re.DOTALL)
|
||||
if brace_match:
|
||||
try:
|
||||
return json.loads(brace_match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback : texte brut
|
||||
return {"answer": content, "data": None, "chart_config": None, "sql_query": None}
|
||||
|
||||
|
||||
async def check_ollama_health() -> dict:
|
||||
"""Vérifie la disponibilité d'Ollama.
|
||||
|
||||
Returns:
|
||||
dict avec ollama_available (bool) et model (str).
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(f"{OLLAMA_URL}/api/tags")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
models = [m.get("name", "") for m in data.get("models", [])]
|
||||
available = any(OLLAMA_MODEL in m for m in models)
|
||||
return {"ollama_available": available, "model": OLLAMA_MODEL}
|
||||
except Exception:
|
||||
pass
|
||||
return {"ollama_available": False, "model": OLLAMA_MODEL}
|
||||
|
||||
|
||||
async def ask_ollama(question: str, chart_type: str = "auto") -> dict:
|
||||
"""Envoie une question à Ollama avec boucle tool-calling.
|
||||
|
||||
Args:
|
||||
question: Question en langage naturel.
|
||||
chart_type: Type de graphique forcé ("auto" laisse le LLM choisir).
|
||||
|
||||
Returns:
|
||||
dict avec answer, data, chart_config, sql_query.
|
||||
|
||||
Raises:
|
||||
httpx.TimeoutException: si Ollama ne répond pas dans le délai.
|
||||
httpx.HTTPError: si Ollama retourne une erreur.
|
||||
"""
|
||||
user_content = question
|
||||
if chart_type != "auto":
|
||||
user_content += f"\n\nIMPORTANT : Utilise obligatoirement un graphique de type '{chart_type}' (ne choisis pas un autre type)."
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
|
||||
for iteration in range(MAX_TOOL_ITERATIONS):
|
||||
payload = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"tools": TOOLS,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
logger.info(f"Ollama iteration {iteration + 1}/{MAX_TOOL_ITERATIONS}")
|
||||
resp = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
assistant_message = data.get("message", {})
|
||||
messages.append(assistant_message)
|
||||
|
||||
tool_calls = assistant_message.get("tool_calls")
|
||||
if not tool_calls:
|
||||
# Pas de tool calls → réponse finale
|
||||
content = assistant_message.get("content", "")
|
||||
return _parse_response(content)
|
||||
|
||||
# Exécuter chaque tool call
|
||||
for tool_call in tool_calls:
|
||||
func = tool_call.get("function", {})
|
||||
name = func.get("name", "")
|
||||
arguments = func.get("arguments", {})
|
||||
|
||||
logger.info(f"Tool call: {name}({json.dumps(arguments, ensure_ascii=False)[:200]})")
|
||||
result = _execute_tool(name, arguments)
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": result,
|
||||
})
|
||||
|
||||
# Si on atteint la limite d'itérations
|
||||
return {
|
||||
"answer": "Désolé, je n'ai pas pu trouver de réponse après plusieurs tentatives.",
|
||||
"data": None,
|
||||
"chart_config": None,
|
||||
"sql_query": None,
|
||||
}
|
||||
181
src/plesna_gerance/services/sql_executor.py
Normal file
181
src/plesna_gerance/services/sql_executor.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Exécution SQL read-only sécurisée pour l'assistant IA."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from ..database.connection import get_db_path
|
||||
|
||||
|
||||
# Requêtes interdites (défense en profondeur)
|
||||
_FORBIDDEN_PATTERN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# PRAGMA autorisés
|
||||
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
|
||||
|
||||
# Limite de résultats par défaut
|
||||
MAX_ROWS = 500
|
||||
|
||||
|
||||
def _validate_sql(query: str) -> None:
|
||||
"""Valide qu'une requête SQL est read-only.
|
||||
|
||||
Raises:
|
||||
ValueError: si la requête n'est pas autorisée.
|
||||
"""
|
||||
stripped = query.strip().rstrip(";").strip()
|
||||
upper = stripped.upper()
|
||||
|
||||
# Autoriser les PRAGMA spécifiques
|
||||
if upper.startswith("PRAGMA"):
|
||||
pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
|
||||
if pragma_name not in _ALLOWED_PRAGMAS:
|
||||
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
|
||||
return
|
||||
|
||||
# La requête doit commencer par SELECT ou WITH
|
||||
if not (upper.startswith("SELECT") or upper.startswith("WITH")):
|
||||
raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées")
|
||||
|
||||
# Vérifier l'absence de mots-clés dangereux
|
||||
match = _FORBIDDEN_PATTERN.search(stripped)
|
||||
if match:
|
||||
raise ValueError(f"Mot-clé SQL interdit détecté : {match.group()}")
|
||||
|
||||
|
||||
def _ensure_limit(query: str) -> str:
|
||||
"""Ajoute LIMIT si absent."""
|
||||
upper = query.strip().rstrip(";").upper()
|
||||
if "LIMIT" not in upper:
|
||||
return query.rstrip().rstrip(";") + f" LIMIT {MAX_ROWS}"
|
||||
return query
|
||||
|
||||
|
||||
def execute_readonly_sql(query: str) -> dict:
|
||||
"""Exécute une requête SQL en mode read-only.
|
||||
|
||||
Args:
|
||||
query: Requête SQL à exécuter.
|
||||
|
||||
Returns:
|
||||
dict avec columns, rows, row_count.
|
||||
|
||||
Raises:
|
||||
ValueError: si la requête n'est pas autorisée.
|
||||
sqlite3.Error: si l'exécution échoue.
|
||||
"""
|
||||
_validate_sql(query)
|
||||
query = _ensure_limit(query)
|
||||
|
||||
db_path = get_db_path()
|
||||
uri = f"file:{db_path}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
cursor = conn.execute(query)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
rows = cursor.fetchall()
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": [list(row) for row in rows],
|
||||
"row_count": len(rows),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_schema_description() -> str:
|
||||
"""Retourne une description textuelle du schéma de la base de données."""
|
||||
return """Base de données de gestion immobilière (SQLite).
|
||||
|
||||
Tables et colonnes :
|
||||
|
||||
1. **tags** — Catégorisation des dépenses
|
||||
- id (INTEGER, PK)
|
||||
- nom (VARCHAR(100), UNIQUE) — nom du tag (ex: "Assurance", "Entretien", "Travaux")
|
||||
- created_at (DATETIME)
|
||||
Relation : une dépense peut avoir un tag_id
|
||||
|
||||
2. **immeubles** — Immeubles gérés
|
||||
- id (INTEGER, PK)
|
||||
- code (VARCHAR(20), UNIQUE) — code de référence de l'immeuble
|
||||
- adresse (VARCHAR(255)) — adresse postale
|
||||
- ville (VARCHAR(100))
|
||||
- code_postal (VARCHAR(10))
|
||||
- created_at (DATETIME)
|
||||
|
||||
3. **lots** — Lots (appartements, locaux commerciaux) au sein d'un immeuble
|
||||
- id (INTEGER, PK)
|
||||
- immeuble_id (INTEGER, FK → immeubles.id)
|
||||
- numero (VARCHAR(10)) — numéro du lot dans l'immeuble
|
||||
- type (VARCHAR(50)) — type de lot ("Appartement", "Loc. Commercial", etc.)
|
||||
- created_at (DATETIME)
|
||||
Contrainte : (immeuble_id, numero) est unique
|
||||
|
||||
4. **locataires** — Locataires avec historique d'occupation
|
||||
- id (INTEGER, PK)
|
||||
- lot_id (INTEGER, FK → lots.id)
|
||||
- nom (VARCHAR(255)) — nom du locataire
|
||||
- date_debut (DATE) — date d'entrée dans le lot
|
||||
- date_fin (DATE) — date de sortie (NULL si toujours actif)
|
||||
- created_at (DATETIME)
|
||||
|
||||
5. **documents** — Documents PDF importés (comptes rendus de gérance)
|
||||
- id (INTEGER, PK)
|
||||
- reference (VARCHAR(50)) — référence du document
|
||||
- date (DATE) — date du document
|
||||
- type (VARCHAR(100)) — type de document ("COMPTE RENDU DE GESTION")
|
||||
- source_file (VARCHAR(255)) — nom du fichier PDF original
|
||||
- immeuble_id (INTEGER, FK → immeubles.id)
|
||||
- json_data (TEXT) — données JSON brutes extraites du PDF
|
||||
- editeur_nom (VARCHAR(255)) — nom de l'éditeur du document
|
||||
- editeur_siret (VARCHAR(20))
|
||||
- solde_montant (FLOAT) — solde à la date du document
|
||||
- solde_type (VARCHAR(20)) — "crediteur" ou "debiteur"
|
||||
- solde_date_arrete (DATE)
|
||||
- pdf_path (VARCHAR(500)) — chemin relatif du PDF stocké
|
||||
- json_path (VARCHAR(500)) — chemin relatif du JSON stocké
|
||||
- created_at (DATETIME)
|
||||
|
||||
6. **revenus** — Revenus locatifs (lignes détaillées par locataire/lot/document)
|
||||
- id (INTEGER, PK)
|
||||
- document_id (INTEGER, FK → documents.id)
|
||||
- lot_id (INTEGER, FK → lots.id)
|
||||
- locataire_id (INTEGER, FK → locataires.id)
|
||||
- type_ligne (VARCHAR(50)) — type : "loyer", "solde_anterieur", "rappel_loyer", "divers"
|
||||
- periode_debut (DATE) — début de la période concernée
|
||||
- periode_fin (DATE) — fin de la période concernée
|
||||
- loyers (FLOAT) — montant des loyers
|
||||
- taxes (FLOAT) — montant des taxes
|
||||
- provisions (FLOAT) — provisions sur charges
|
||||
- divers_montant (FLOAT)
|
||||
- divers_libelle (VARCHAR(255))
|
||||
- total (FLOAT) — total de la ligne
|
||||
- regles (FLOAT) — montant réglé par le locataire
|
||||
- impayes (FLOAT) — montant impayé
|
||||
- created_at (DATETIME)
|
||||
|
||||
7. **depenses** — Dépenses et opérations sur les immeubles
|
||||
- id (INTEGER, PK)
|
||||
- document_id (INTEGER, FK → documents.id)
|
||||
- immeuble_id (INTEGER, FK → immeubles.id)
|
||||
- lot_id (INTEGER, FK → lots.id, nullable) — NULL si dépense au niveau immeuble
|
||||
- tag_id (INTEGER, FK → tags.id, nullable) — tag de catégorisation manuelle
|
||||
- categorie (VARCHAR(100)) — catégorie (ex: "DEPENSES_LOCATIVES", "CHARGES_COPROPRIETE")
|
||||
- sous_categorie (VARCHAR(255)) — sous-catégorie (ex: "Nettoyage immeuble")
|
||||
- fournisseur (VARCHAR(255)) — nom du fournisseur
|
||||
- description (VARCHAR(500)) — libellé de la dépense
|
||||
- debit (FLOAT) — montant débité
|
||||
- credit (FLOAT) — montant crédité
|
||||
- tva (FLOAT) — montant de TVA
|
||||
- locatif (FLOAT) — part locative
|
||||
- deductible (FLOAT) — part déductible fiscalement
|
||||
- created_at (DATETIME)
|
||||
|
||||
Relations principales :
|
||||
- Un immeuble contient plusieurs lots, documents et dépenses
|
||||
- Un lot appartient à un immeuble et contient des locataires, revenus et dépenses
|
||||
- Un document est lié à un immeuble et contient des revenus et dépenses
|
||||
- Un revenu est lié à un document, un lot et un locataire
|
||||
- Une dépense est liée à un document, un immeuble, optionnellement un lot et un tag"""
|
||||
Reference in New Issue
Block a user