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

@@ -34,14 +34,22 @@
Revenus
</router-link>
<router-link
to="/analytics"
<router-link
to="/analytics"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/analytics' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Depenses
</router-link>
<router-link
to="/ia"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/ia' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
IA
</router-link>
<!-- Import button with file input -->
<label
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"

View File

@@ -0,0 +1,313 @@
<template>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-5xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Assistant IA</h1>
<p class="text-gray-400 text-sm mt-1">Posez des questions sur vos données en langage naturel</p>
</div>
<div class="flex items-center gap-2">
<span
class="inline-block w-2.5 h-2.5 rounded-full"
:class="ollamaAvailable ? 'bg-green-400' : 'bg-red-400'"
></span>
<span class="text-sm" :class="ollamaAvailable ? 'text-green-400' : 'text-red-400'">
{{ ollamaAvailable ? `Ollama (${ollamaModel})` : 'Ollama indisponible' }}
</span>
</div>
</div>
<!-- Formulaire -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<div class="flex gap-3">
<input
v-model="question"
type="text"
placeholder="Posez votre question..."
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
@keydown.enter="askQuestion"
:disabled="loading"
/>
<select
v-model="chartType"
class="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
:disabled="loading"
>
<option value="auto">Auto</option>
<option value="bar">Barres</option>
<option value="line">Lignes</option>
<option value="pie">Camembert</option>
<option value="doughnut">Anneau</option>
</select>
<button
@click="askQuestion"
:disabled="!question.trim() || loading"
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700"
>
{{ loading ? 'Analyse...' : 'Demander' }}
</button>
</div>
</div>
<!-- Exemples de questions -->
<div v-if="!result && !loading && !error" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<p class="text-gray-400 text-sm mb-3">Exemples de questions :</p>
<div class="flex flex-wrap gap-2">
<button
v-for="example in examples"
:key="example"
@click="question = example; askQuestion()"
class="px-3 py-1.5 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-300 hover:border-blue-500 hover:text-white transition-colors"
>
{{ example }}
</button>
</div>
</div>
<!-- Spinner -->
<div v-if="loading" class="bg-gray-800 rounded-lg border border-gray-700 p-8 text-center">
<div class="inline-block w-8 h-8 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin mb-3"></div>
<p class="text-gray-400">{{ loadingMessage }}</p>
</div>
<!-- Erreur -->
<div v-if="error" class="bg-red-900/20 border border-red-800 rounded-lg p-4">
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Résultat -->
<div v-if="result" class="space-y-4">
<!-- Réponse texte -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Réponse</h2>
<p class="text-white whitespace-pre-wrap">{{ result.answer }}</p>
</div>
<!-- Requête SQL -->
<div v-if="result.sql_query" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Requête SQL exécutée</h2>
<pre class="text-xs text-gray-300 font-mono bg-gray-900 rounded p-3 overflow-x-auto">{{ result.sql_query }}</pre>
</div>
<!-- Graphique -->
<div v-if="result.chart_config && chartComponent" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">Graphique</h2>
<div class="bg-gray-900 rounded-lg p-4">
<component
:is="chartComponent"
:data="result.chart_config.data"
:options="mergedChartOptions"
/>
</div>
</div>
<!-- Tableau de données -->
<div v-if="result.data && result.data.columns && result.data.rows && result.data.rows.length > 0" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">
Données ({{ result.data.row_count }} ligne{{ result.data.row_count > 1 ? 's' : '' }})
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700">
<th
v-for="col in result.data.columns"
:key="col"
class="text-left px-3 py-2 text-gray-400 font-medium"
>
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in result.data.rows"
:key="idx"
class="border-b border-gray-700/50 hover:bg-gray-700/30"
>
<td
v-for="(cell, cidx) in row"
:key="cidx"
class="px-3 py-2 text-gray-300"
>
{{ formatCell(cell) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Bar, Line, Doughnut, Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
)
const question = ref('')
const chartType = ref('auto')
const loading = ref(false)
const loadingMessage = ref('Envoi de la question...')
const result = ref(null)
const error = ref(null)
const ollamaAvailable = ref(false)
const ollamaModel = ref('')
const examples = [
'Combien d\'immeubles dans la base ?',
'Total des dépenses par immeuble ?',
'Top 5 fournisseurs les plus chers ?',
'Évolution mensuelle des revenus ?',
'Quels sont les locataires avec des impayés ?',
'Répartition des dépenses par catégorie ?',
]
const chartComponentMap = {
bar: Bar,
line: Line,
doughnut: Doughnut,
pie: Pie,
}
const chartComponent = computed(() => {
if (!result.value?.chart_config?.type) return null
return chartComponentMap[result.value.chart_config.type] || null
})
const darkThemeDefaults = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
labels: { color: 'rgb(156, 163, 175)' },
},
title: {
color: 'rgb(229, 231, 235)',
},
},
scales: {
x: {
ticks: { color: 'rgb(156, 163, 175)' },
grid: { color: 'rgb(55, 65, 81)' },
},
y: {
ticks: { color: 'rgb(156, 163, 175)' },
grid: { color: 'rgb(55, 65, 81)' },
},
},
}
const mergedChartOptions = computed(() => {
const llmOptions = result.value?.chart_config?.options || {}
const chartType = result.value?.chart_config?.type
// Doughnut/Pie don't use scales
if (chartType === 'doughnut' || chartType === 'pie') {
const { scales, ...defaults } = darkThemeDefaults
return deepMerge(defaults, llmOptions)
}
return deepMerge(darkThemeDefaults, llmOptions)
})
function deepMerge(target, source) {
const output = { ...target }
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
output[key] = deepMerge(output[key] || {}, source[key])
} else {
output[key] = source[key]
}
}
return output
}
function formatCell(value) {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') {
return Number.isInteger(value) ? value.toString() : value.toFixed(2)
}
return String(value)
}
async function checkHealth() {
try {
const resp = await fetch('/api/ia/health')
if (resp.ok) {
const data = await resp.json()
ollamaAvailable.value = data.ollama_available
ollamaModel.value = data.model
}
} catch {
ollamaAvailable.value = false
}
}
async function askQuestion() {
const q = question.value.trim()
if (!q || loading.value) return
loading.value = true
loadingMessage.value = 'Envoi de la question...'
result.value = null
error.value = null
// Messages progressifs
const timer = setTimeout(() => {
loadingMessage.value = 'Analyse des données en cours...'
}, 3000)
const timer2 = setTimeout(() => {
loadingMessage.value = 'Génération de la réponse...'
}, 8000)
try {
const resp = await fetch('/api/ia/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q, chart_type: chartType.value }),
})
if (!resp.ok) {
const data = await resp.json().catch(() => ({}))
throw new Error(data.detail || `Erreur ${resp.status}`)
}
result.value = await resp.json()
} catch (e) {
error.value = e.message || 'Une erreur est survenue.'
} finally {
clearTimeout(timer)
clearTimeout(timer2)
loading.value = false
}
}
onMounted(checkHealth)
</script>

View File

@@ -5,6 +5,7 @@ import AnalyticsPage from './pages/AnalyticsPage.vue'
import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue'
import IAPage from './pages/IAPage.vue'
const routes = [
{
@@ -27,6 +28,11 @@ const routes = [
name: 'revenus',
component: RevenusPage
},
{
path: '/ia',
name: 'ia',
component: IAPage
},
{
path: '/documents',
name: 'documents',

View File

@@ -9,6 +9,7 @@ dependencies = [
"uvicorn[standard]>=0.20.0",
"python-multipart>=0.0.6",
"sqlalchemy>=2.0.0",
"httpx>=0.24.0",
]
[project.scripts]

View File

@@ -15,6 +15,7 @@ from .routes import (
analytics_router,
dashboard_router,
revenus_router,
ia_router,
)
app = FastAPI(
@@ -41,6 +42,7 @@ app.include_router(tags_router)
app.include_router(analytics_router)
app.include_router(dashboard_router)
app.include_router(revenus_router)
app.include_router(ia_router)
# Health check endpoints (keep in main app)

View File

@@ -6,6 +6,7 @@ from .tags import router as tags_router
from .analytics import router as analytics_router
from .dashboard import router as dashboard_router
from .revenus import router as revenus_router
from .ia import router as ia_router
__all__ = [
"extraction_router",
@@ -14,4 +15,5 @@ __all__ = [
"analytics_router",
"dashboard_router",
"revenus_router",
"ia_router",
]

View File

@@ -0,0 +1,88 @@
"""Routes IA — Requêtes en langage naturel via Ollama."""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import httpx
from ...services.ollama_service import ask_ollama, check_ollama_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ia", tags=["ia"])
# ============================================================
# Request / Response models
# ============================================================
class AskRequest(BaseModel):
question: str
chart_type: str = "auto"
class AskResponse(BaseModel):
answer: str | None = None
data: dict | None = None
chart_config: dict | None = None
sql_query: str | None = None
error: str | None = None
class HealthResponse(BaseModel):
ollama_available: bool
model: str
# ============================================================
# Endpoints
# ============================================================
@router.post("/ask", response_model=AskResponse)
async def ask_ia(request: AskRequest) -> AskResponse:
"""Pose une question en langage naturel et obtient une réponse via Ollama.
Le LLM génère les requêtes SQL nécessaires, exécute les outils,
et retourne une réponse avec données et graphique optionnel.
"""
if not request.question.strip():
raise HTTPException(status_code=400, detail="La question ne peut pas être vide.")
try:
result = await ask_ollama(request.question, request.chart_type)
return AskResponse(
answer=result.get("answer"),
data=result.get("data"),
chart_config=result.get("chart_config"),
sql_query=result.get("sql_query"),
)
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="Ollama n'a pas répondu dans le délai imparti.",
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="Impossible de se connecter à Ollama. Vérifiez qu'il est lancé.",
)
except httpx.HTTPStatusError as e:
logger.error(f"Ollama HTTP error: {e}")
raise HTTPException(
status_code=503,
detail=f"Erreur Ollama : {e.response.status_code}",
)
except Exception as e:
logger.error(f"IA ask error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health", response_model=HealthResponse)
async def ia_health() -> HealthResponse:
"""Vérifie la disponibilité d'Ollama et du modèle configuré."""
result = await check_ollama_health()
return HealthResponse(**result)

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

39
uv.lock generated
View File

@@ -34,6 +34,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@@ -140,6 +149,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
@@ -183,6 +205,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -199,6 +236,7 @@ source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
@@ -208,6 +246,7 @@ dependencies = [
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "python-multipart", specifier = ">=0.0.6" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" },