diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py index 447c71e..2d8266a 100644 --- a/artifacts/flask-api/ai_agent.py +++ b/artifacts/flask-api/ai_agent.py @@ -11,6 +11,7 @@ import logging from openai import OpenAI, BadRequestError from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT, build_filter_prompt from legifrance_client import get_legifrance_client +from database import get_setting logger = logging.getLogger(__name__) @@ -36,6 +37,16 @@ def _get_live_legal_texts() -> dict[str, str]: return {} +def get_model(kind: str) -> str: + """kind = "filter" | "synthesis". Priorité : réglage admin (DB) > env > défaut.""" + default = "mistral-small-latest" if kind == "filter" else "mistral-large-latest" + try: + saved = get_setting(f"{kind}_model") + except Exception: + saved = None + return saved or os.environ.get(f"{kind.upper()}_MODEL", os.environ.get(f"OPENAI_{kind.upper()}_MODEL", default)) + + def get_client() -> OpenAI: """ Supporte deux modes (par ordre de priorité) : @@ -75,7 +86,7 @@ def filter_idea(content: str) -> dict: """ try: client = get_client() - filter_model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest")) + filter_model = get_model("filter") live_texts = _get_live_legal_texts() system_prompt = build_filter_prompt(live_texts) if live_texts: @@ -137,7 +148,7 @@ def check_existing_law(content: str) -> str: """ try: client = get_client() - model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest")) + model = get_model("filter") response = client.chat.completions.create( model=model, max_tokens=200, @@ -165,7 +176,7 @@ def synthesize_ideas(ideas: list[str]) -> str: ) try: client = get_client() - synthesis_model = os.environ.get("SYNTHESIS_MODEL", os.environ.get("OPENAI_SYNTHESIS_MODEL", "mistral-large-latest")) + synthesis_model = get_model("synthesis") ideas_text = "\n".join(f"{i + 1}. {idea}" for i, idea in enumerate(ideas)) response = client.chat.completions.create( model=synthesis_model, diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 4282d0a..62d4fc3 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -58,8 +58,9 @@ from database import ( close_consultation, get_consultations_to_autoclose, get_consultation_stats, get_consultation_contributions, delete_consultation, check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist, + set_setting, ) -from ai_agent import filter_idea, synthesize_ideas, check_existing_law +from ai_agent import filter_idea, synthesize_ideas, check_existing_law, get_client, get_model # ─── Logging ──────────────────────────────────────────────────────────────── @@ -1290,6 +1291,51 @@ def admin_regenerate_synthesis(): return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."}) +@app.get("/api/admin/ai/models") +@require_admin +def admin_ai_models(): + """Modèles actuellement actifs + liste de ceux accessibles avec la clé en place.""" + out = {"filter": get_model("filter"), "synthesis": get_model("synthesis"), "available": [], "error": None} + try: + out["available"] = sorted(m.id for m in get_client().models.list().data) + except Exception as e: + logger.warning("Admin — liste des modèles IA impossible : %s", type(e).__name__) + out["error"] = f"{type(e).__name__}: {str(e)[:200]}" + return jsonify(out) + + +@app.post("/api/admin/ai/models") +@require_admin +def admin_ai_set_models(): + """Enregistre les modèles filter/synthesis (chaîne vide → retour à l'env).""" + body = request.get_json(silent=True) or {} + for kind in ("filter", "synthesis"): + if kind in body: + v = str(body[kind] or "").strip() + if len(v) > 100: + return jsonify({"error": "invalid_model"}), 400 + set_setting(f"{kind}_model", v or None) + logger.info("Admin — modèles IA mis à jour") + return jsonify({"ok": True, "filter": get_model("filter"), "synthesis": get_model("synthesis")}) + + +@app.post("/api/admin/ai/test") +@require_admin +@limiter.limit("10 per minute") +def admin_ai_test(): + """Appel minimal (1 token) pour vérifier qu'un modèle répond avec la clé en place.""" + model = str((request.get_json(silent=True) or {}).get("model", "")).strip() + if not model or len(model) > 100: + return jsonify({"error": "invalid_model"}), 400 + try: + get_client().chat.completions.create( + model=model, max_tokens=1, messages=[{"role": "user", "content": "ping"}], + ) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": f"{type(e).__name__}: {str(e)[:200]}"}) + + @app.get("/api/admin/export/csv") @require_admin def admin_export_csv(): diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index e25bc46..eb09283 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -114,6 +114,7 @@ def init_db() -> None: ) """) cur.execute("CREATE INDEX IF NOT EXISTS idx_ip_abuse_expires ON ip_abuse(expires_at)") + cur.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") logger.info("Base de données initialisée.") @@ -575,3 +576,25 @@ def delete_consultation(consultation_id: int) -> bool: cur.execute("DELETE FROM synthesis WHERE consultation_id = %s", (consultation_id,)) cur.execute("DELETE FROM consultations WHERE id = %s RETURNING id", (consultation_id,)) return cur.fetchone() is not None + + +# ─── Réglages (clé/valeur) ──────────────────────────────────────────────────── + +def get_setting(key: str) -> str | None: + with db_cursor() as cur: + cur.execute("SELECT value FROM settings WHERE key = %s", (key,)) + row = cur.fetchone() + return row["value"] if row else None + + +def set_setting(key: str, value: str | None) -> None: + """value=None → supprime le réglage (retour à la valeur d'environnement).""" + with db_cursor() as cur: + if value is None: + cur.execute("DELETE FROM settings WHERE key = %s", (key,)) + else: + cur.execute( + "INSERT INTO settings (key, value) VALUES (%s, %s) " + "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value", + (key, value), + ) diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx index 014313d..73aca64 100644 --- a/artifacts/voix-du-peuple/src/pages/admin.tsx +++ b/artifacts/voix-du-peuple/src/pages/admin.tsx @@ -670,13 +670,78 @@ function BlacklistPanel() { ); } +function AiModelsPanel() { + const { toast } = useToast(); + const [data, setData] = useState<{ filter: string; synthesis: string; available: string[]; error: string | null } | null>(null); + const [filter, setFilter] = useState(""); + const [synthesis, setSynthesis] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const res = await adminFetch("/api/admin/ai/models"); + if (!res.ok) return; + const d = await res.json(); + setData(d); setFilter(d.filter); setSynthesis(d.synthesis); + }, []); + useEffect(() => { load(); }, [load]); + + const test = async (model: string) => { + setBusy(true); + const r = await adminFetch("/api/admin/ai/test", { method: "POST", body: JSON.stringify({ model }) }); + const d = await r.json().catch(() => ({})); + setBusy(false); + toast(d.ok + ? { title: "Modèle OK", description: model } + : { title: "Échec", description: d.error ?? `Erreur ${r.status}`, variant: "destructive" }); + return !!d.ok; + }; + + const save = async () => { + setBusy(true); + const r = await adminFetch("/api/admin/ai/models", { method: "POST", body: JSON.stringify({ filter, synthesis }) }); + setBusy(false); + toast(r.ok ? { title: "Modèles enregistrés" } : { title: "Erreur", variant: "destructive" }); + if (r.ok) load(); + }; + + const opts = data?.available ?? []; + const field = (label: string, value: string, set: (v: string) => void) => ( +
La clé se change dans MISTRAL_API_KEY (.env) puis redémarrage de l'API.