feat: first IA page

This commit is contained in:
2026-02-13 12:18:24 +01:00
parent 5c9a8c9b5e
commit 4fe6a4b2c9
10 changed files with 910 additions and 3 deletions

View 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,
}

View 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"""