feat: add settings service and config API routes

Add a key-value settings table (Setting model) with DB > env > default
resolution chain. Replace hardcoded Ollama constants in ollama_service
with dynamic _get_config() lookups. Add /api/config/ endpoints for
settings CRUD and tag management (create, rename).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 16:09:02 +01:00
parent 4fe6a4b2c9
commit a1d708d083
7 changed files with 283 additions and 14 deletions

View File

@@ -2,21 +2,31 @@
import json
import logging
import os
import httpx
from .sql_executor import execute_readonly_sql, get_schema_description
from .settings_service import get_setting
from ..database.connection import get_session_factory
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
def _get_config() -> dict:
"""Récupère la config Ollama via le service settings (DB > env > default)."""
SessionLocal = get_session_factory()
session = SessionLocal()
try:
return {
"url": get_setting(session, "ollama_url"),
"model": get_setting(session, "ollama_model"),
"timeout": int(get_setting(session, "ollama_timeout")),
}
finally:
session.close()
# Définition des tools pour l'API Ollama
TOOLS = [
{
@@ -185,17 +195,18 @@ async def check_ollama_health() -> dict:
Returns:
dict avec ollama_available (bool) et model (str).
"""
config = _get_config()
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{OLLAMA_URL}/api/tags")
resp = await client.get(f"{config['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}
available = any(config["model"] in m for m in models)
return {"ollama_available": available, "model": config["model"]}
except Exception:
pass
return {"ollama_available": False, "model": OLLAMA_MODEL}
return {"ollama_available": False, "model": config["model"]}
async def ask_ollama(question: str, chart_type: str = "auto") -> dict:
@@ -216,22 +227,24 @@ async def ask_ollama(question: str, chart_type: str = "auto") -> dict:
if chart_type != "auto":
user_content += f"\n\nIMPORTANT : Utilise obligatoirement un graphique de type '{chart_type}' (ne choisis pas un autre type)."
config = _get_config()
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
]
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
for iteration in range(MAX_TOOL_ITERATIONS):
payload = {
"model": OLLAMA_MODEL,
"model": config["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 = await client.post(f"{config['url']}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()

View File

@@ -0,0 +1,103 @@
"""Service de gestion des paramètres de configuration.
Chaîne de résolution : valeur DB > variable d'environnement > valeur par défaut.
"""
import os
from sqlalchemy.orm import Session
from ..database.models import Setting
# Registry: key -> (env_var, default_value)
SETTINGS_REGISTRY: dict[str, tuple[str, str]] = {
"ollama_url": ("OLLAMA_URL", "http://localhost:11434"),
"ollama_model": ("OLLAMA_MODEL", "qwen2.5"),
"ollama_timeout": ("OLLAMA_TIMEOUT", "120"),
}
def get_setting(session: Session, key: str) -> str:
"""Résout la valeur d'un setting : DB > env > default.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
env_var, default = SETTINGS_REGISTRY[key]
# 1. Check DB
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
return row.value
# 2. Check env var
env_value = os.environ.get(env_var)
if env_value is not None:
return env_value
# 3. Default
return default
def get_all_settings(session: Session) -> dict[str, dict]:
"""Retourne tous les settings avec valeur et source.
Returns:
dict de la forme {key: {"value": str, "source": "database"|"env"|"default"}}
"""
db_rows = {row.key: row.value for row in session.query(Setting).all()}
result = {}
for key, (env_var, default) in SETTINGS_REGISTRY.items():
if key in db_rows:
result[key] = {"value": db_rows[key], "source": "database"}
elif os.environ.get(env_var) is not None:
result[key] = {"value": os.environ[env_var], "source": "env"}
else:
result[key] = {"value": default, "source": "default"}
return result
def set_setting(session: Session, key: str, value: str) -> Setting:
"""Upsert un setting en base.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
row.value = value
else:
row = Setting(key=key, value=value)
session.add(row)
session.commit()
session.refresh(row)
return row
def delete_setting(session: Session, key: str) -> bool:
"""Supprime l'override DB pour un setting (retour au défaut).
Returns:
True si un override existait, False sinon.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
session.delete(row)
session.commit()
return True
return False