Author SHA1 Message Date
billisdeadandClaude Sonnet 5.5 5679940fa7 Fetch GitHub Models catalog with httpx and documented headers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:52:25 +02:00
billisdead 51d91b91c4 Merge pull request 'Parse GitHub Models catalog response defensively' (#4) from fix/github-catalog-parsing into main 2026-10-12 14:50:55 +02:00
billisdeadandClaude Sonnet 5.5 9368f64a9b Parse GitHub Models catalog response defensively
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:50:17 +02:00
billisdead c53330c3bd Merge pull request 'Fix model list for GitHub Models via its catalog endpoint' (#3) from fix/github-models-catalog into main 2026-10-12 14:48:28 +02:00
billisdead 80ee96444e Merge pull request 'Ignore local GitHub token file' (#2) from chore/ignore-github-token into main 2026-10-12 14:48:25 +02:00
billisdeadandClaude Sonnet 5.5 5721a0a36f Fix model list for GitHub Models via its catalog endpoint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:47:08 +02:00
billisdeadandClaude Sonnet 5.5 f749cba306 Ignore local GitHub token file
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:46:30 +02:00
billisdead bdd7a9f7bf Merge pull request 'Add admin AI models panel' (#1) from feat/admin-ai-models into main 2026-10-12 09:56:03 +02:00
billisdeadandClaude Sonnet 5.5 13677b3ce3 Add admin AI models panel (list, test, select Mistral models)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:55:37 +02:00
5 changed files with 191 additions and 5 deletions

No files matched your search

+3
View File
@@ -56,3 +56,6 @@ SECURITY_REVIEW.md
Thumbs.db Thumbs.db
*.sass-cache *.sass-cache
coverage/ coverage/
# Secrets locaux
.github-token.txt
+40 -3
View File
@@ -8,9 +8,11 @@ Supporte Mistral AI (par défaut) et tout fournisseur compatible OpenAI.
import json import json
import os import os
import logging import logging
import httpx
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 +38,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é) :
@@ -66,6 +78,31 @@ def get_client() -> OpenAI:
return _client return _client
GITHUB_CATALOG_URL = "https://models.github.ai/catalog/models"
def list_model_ids() -> list[str]:
"""Modèles accessibles avec la clé en place (GitHub Models : catalogue dédié, /models n'est pas OpenAI-compatible)."""
client = get_client()
if "models.github.ai" in str(client.base_url):
r = httpx.get(
GITHUB_CATALOG_URL,
headers={
"Authorization": f"Bearer {client.api_key}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=10,
)
if r.status_code != 200 or not r.content:
raise RuntimeError(f"catalogue GitHub HTTP {r.status_code} : {r.text[:120]!r}")
raw = r.json()
if isinstance(raw, dict):
raw = next((v for v in raw.values() if isinstance(v, list)), [])
return sorted(m["id"] if isinstance(m, dict) else str(m) for m in raw)
return sorted(m.id for m in client.models.list().data)
def filter_idea(content: str) -> dict: def filter_idea(content: str) -> dict:
""" """
Filtre une idée selon le cadre légal international des droits humains Filtre une idée selon le cadre légal international des droits humains
@@ -75,7 +112,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 +174,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 +202,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,
+47 -1
View File
@@ -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, list_model_ids
# ─── 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"] = list_model_ids()
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():
+23
View File
@@ -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),
)
+78 -1
View File
@@ -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 />