feat: first IA page
This commit is contained in:
@@ -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"
|
||||
|
||||
313
frontend/src/pages/IAPage.vue
Normal file
313
frontend/src/pages/IAPage.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
88
src/plesna_gerance/api/routes/ia.py
Normal file
88
src/plesna_gerance/api/routes/ia.py
Normal 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)
|
||||
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"""
|
||||
39
uv.lock
generated
39
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user