Add admin AI models panel #1
No files matched your search
@@ -11,6 +11,7 @@ import logging
|
|||||||
from openai import OpenAI, BadRequestError
|
from openai import OpenAI, BadRequestError
|
||||||
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT, build_filter_prompt
|
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT, build_filter_prompt
|
||||||
from legifrance_client import get_legifrance_client
|
from legifrance_client import get_legifrance_client
|
||||||
|
from database import get_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +37,16 @@ def _get_live_legal_texts() -> dict[str, str]:
|
|||||||
return {}
|
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:
|
def get_client() -> OpenAI:
|
||||||
"""
|
"""
|
||||||
Supporte deux modes (par ordre de priorité) :
|
Supporte deux modes (par ordre de priorité) :
|
||||||
@@ -75,7 +86,7 @@ def filter_idea(content: str) -> dict:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
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()
|
live_texts = _get_live_legal_texts()
|
||||||
system_prompt = build_filter_prompt(live_texts)
|
system_prompt = build_filter_prompt(live_texts)
|
||||||
if live_texts:
|
if live_texts:
|
||||||
@@ -137,7 +148,7 @@ def check_existing_law(content: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
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(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=200,
|
max_tokens=200,
|
||||||
@@ -165,7 +176,7 @@ def synthesize_ideas(ideas: list[str]) -> str:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
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))
|
ideas_text = "\n".join(f"{i + 1}. {idea}" for i, idea in enumerate(ideas))
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=synthesis_model,
|
model=synthesis_model,
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ from database import (
|
|||||||
close_consultation, get_consultations_to_autoclose, get_consultation_stats,
|
close_consultation, get_consultations_to_autoclose, get_consultation_stats,
|
||||||
get_consultation_contributions, delete_consultation,
|
get_consultation_contributions, delete_consultation,
|
||||||
check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist,
|
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 ────────────────────────────────────────────────────────────────
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1290,6 +1291,51 @@ def admin_regenerate_synthesis():
|
|||||||
return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."})
|
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")
|
@app.get("/api/admin/export/csv")
|
||||||
@require_admin
|
@require_admin
|
||||||
def admin_export_csv():
|
def admin_export_csv():
|
||||||
|
|||||||
@@ -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 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.")
|
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 synthesis WHERE consultation_id = %s", (consultation_id,))
|
||||||
cur.execute("DELETE FROM consultations WHERE id = %s RETURNING id", (consultation_id,))
|
cur.execute("DELETE FROM consultations WHERE id = %s RETURNING id", (consultation_id,))
|
||||||
return cur.fetchone() is not None
|
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),
|
||||||
|
)
|
||||||
@@ -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) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">{label}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input list="ai-models" value={value} onChange={e => set(e.target.value)} className="font-mono text-xs" />
|
||||||
|
<Button size="sm" variant="outline" disabled={busy || !value} onClick={() => test(value)}>Tester</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-serif font-semibold text-lg text-primary">Modèles IA</h2>
|
||||||
|
{data?.error && (
|
||||||
|
<div className="text-xs border border-destructive/40 rounded-md p-3 bg-destructive/5 text-destructive">
|
||||||
|
Clé API inutilisable ou fournisseur injoignable : <span className="font-mono">{data.error}</span>
|
||||||
|
<p className="mt-1 text-muted-foreground">La clé se change dans <code>MISTRAL_API_KEY</code> (.env) puis redémarrage de l'API.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<datalist id="ai-models">{opts.map(m => <option key={m} value={m} />)}</datalist>
|
||||||
|
{field("Modèle de filtrage (rapide)", filter, setFilter)}
|
||||||
|
{field("Modèle de synthèse (qualité)", synthesis, setSynthesis)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" onClick={save} disabled={busy}>Enregistrer</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">{opts.length} modèle(s) accessible(s) avec la clé actuelle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Composant principal Admin ────────────────────────────────────────────────
|
// ─── Composant principal Admin ────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { authed, login, logout } = useAdminAuth();
|
const { authed, login, logout } = useAdminAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist">("contributions");
|
const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist" | "ia">("contributions");
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [list, setList] = useState<IdeaList | null>(null);
|
const [list, setList] = useState<IdeaList | null>(null);
|
||||||
const [status, setStatus] = useState("all");
|
const [status, setStatus] = useState("all");
|
||||||
@@ -875,6 +940,16 @@ export default function Admin() {
|
|||||||
>
|
>
|
||||||
<ShieldBan className="h-3 w-3" /> Blacklist
|
<ShieldBan className="h-3 w-3" /> Blacklist
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActivePanel("ia")}
|
||||||
|
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
activePanel === "ia"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
IA
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
@@ -904,6 +979,8 @@ export default function Admin() {
|
|||||||
<ConsultationsPanel />
|
<ConsultationsPanel />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activePanel === "ia" && <AiModelsPanel />}
|
||||||
|
|
||||||
{/* ─── Panel Blacklist IP ─── */}
|
{/* ─── Panel Blacklist IP ─── */}
|
||||||
{activePanel === "blacklist" && (
|
{activePanel === "blacklist" && (
|
||||||
<BlacklistPanel />
|
<BlacklistPanel />
|
||||||
|
|||||||
Reference in new issue
Block a user