mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""Benchmark intégré : évalue objectivement chaque modèle installé.
|
|
|
|
Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki :
|
|
appel d'outil, code exécutable, respect des consignes, extraction JSON,
|
|
respect d'un format. Score /100, stocké en base et affiché dans l'UI.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import AsyncIterator
|
|
|
|
import httpx
|
|
|
|
from . import db
|
|
from .ollama_client import OllamaError, ollama
|
|
|
|
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
|
|
|
|
|
|
async def _ask(model: str, prompt: str, *, system: str = "",
|
|
tools: list | None = None, num_predict: int = 400) -> dict:
|
|
"""Un appel modèle ; renvoie {text, tool_calls}."""
|
|
messages = []
|
|
if system:
|
|
messages.append({"role": "system", "content": system})
|
|
messages.append({"role": "user", "content": prompt})
|
|
text, calls = "", []
|
|
async for chunk in ollama.chat(
|
|
model, messages, tools=tools,
|
|
options={"temperature": 0, "num_predict": num_predict}, stream=True,
|
|
):
|
|
msg = chunk.get("message", {})
|
|
text += msg.get("content", "")
|
|
if msg.get("tool_calls"):
|
|
calls.extend(msg["tool_calls"])
|
|
if chunk.get("done"):
|
|
break
|
|
return {"text": text.strip(), "tool_calls": calls}
|
|
|
|
|
|
def _extract_code(text: str) -> str:
|
|
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S)
|
|
return (m.group(1) if m else text).strip()
|
|
|
|
|
|
def _run_python(code: str, test: str) -> bool:
|
|
"""Exécute code+test dans un sous-processus isolé (timeout 8 s)."""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
|
|
f.write(code + "\n" + test)
|
|
path = f.name
|
|
try:
|
|
proc = subprocess.run(
|
|
[sys.executable, "-I", path],
|
|
capture_output=True, timeout=8,
|
|
)
|
|
return proc.returncode == 0
|
|
except (subprocess.SubprocessError, OSError):
|
|
return False
|
|
|
|
|
|
# ── Les 5 épreuves (score 0-20 chacune) ──────────────────────────────────
|
|
async def _task_tool_call(model: str) -> tuple[int, str]:
|
|
tools = [{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "write_file",
|
|
"description": "Écrire un fichier",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"content": {"type": "string"},
|
|
},
|
|
"required": ["path", "content"],
|
|
},
|
|
},
|
|
}]
|
|
try:
|
|
r = await _ask(
|
|
model,
|
|
"Crée le fichier bonjour.txt contenant exactement le texte : salut",
|
|
system="Utilise l'outil write_file pour créer le fichier demandé.",
|
|
tools=tools, num_predict=200,
|
|
)
|
|
except httpx.HTTPStatusError as exc:
|
|
if "does not support tools" in exc.response.text.lower():
|
|
return 0, "outils non supportés par ce modèle"
|
|
raise
|
|
for tc in r["tool_calls"]:
|
|
fn = tc.get("function", {})
|
|
if fn.get("name") == "write_file":
|
|
args = fn.get("arguments") or {}
|
|
if isinstance(args, str):
|
|
try:
|
|
args = json.loads(args)
|
|
except json.JSONDecodeError:
|
|
return 8, "appel d'outil aux arguments illisibles"
|
|
ok_path = "bonjour" in str(args.get("path", "")).lower()
|
|
ok_content = "salut" in str(args.get("content", "")).lower()
|
|
score = 10 + 5 * ok_path + 5 * ok_content
|
|
return score, "appel d'outil correct" if score == 20 else "appel partiel"
|
|
return 0, "aucun appel d'outil émis"
|
|
|
|
|
|
async def _task_code(model: str) -> tuple[int, str]:
|
|
r = await _ask(
|
|
model,
|
|
"Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme "
|
|
"des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.",
|
|
num_predict=300,
|
|
)
|
|
code = _extract_code(r["text"])
|
|
if "def somme_pairs" not in code:
|
|
return 0, "fonction absente"
|
|
test = (
|
|
"assert somme_pairs([1,2,3,4]) == 6\n"
|
|
"assert somme_pairs([]) == 0\n"
|
|
"assert somme_pairs([7,9]) == 0\n"
|
|
)
|
|
return (20, "code correct (3/3 tests)") if _run_python(code, test) \
|
|
else (6, "code présent mais tests échoués")
|
|
|
|
|
|
async def _task_instruction(model: str) -> tuple[int, str]:
|
|
r = await _ask(
|
|
model,
|
|
"Quelle est la capitale de la France ? Réponds en 3 mots maximum.",
|
|
num_predict=30,
|
|
)
|
|
text = r["text"]
|
|
has_answer = "paris" in text.lower()
|
|
short = len(text.split()) <= 6
|
|
score = 12 * has_answer + 8 * short
|
|
return score, f"réponse « {text[:40]} »"
|
|
|
|
|
|
async def _task_json(model: str) -> tuple[int, str]:
|
|
r = await _ask(
|
|
model,
|
|
'Extrait les informations en JSON strict {"nom": ..., "ville": ...} '
|
|
"depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.",
|
|
num_predict=80,
|
|
)
|
|
m = re.search(r"\{.*\}", r["text"], re.S)
|
|
if not m:
|
|
return 0, "pas de JSON"
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except json.JSONDecodeError:
|
|
return 5, "JSON invalide"
|
|
ok_nom = "marie" in str(data.get("nom", "")).lower()
|
|
ok_ville = "lyon" in str(data.get("ville", "")).lower()
|
|
score = 10 + 5 * ok_nom + 5 * ok_ville
|
|
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
|
|
return score, detail
|
|
|
|
|
|
async def _task_format(model: str) -> tuple[int, str]:
|
|
r = await _ask(
|
|
model,
|
|
"Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».",
|
|
num_predict=60,
|
|
)
|
|
lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")]
|
|
if len(lines) == 3:
|
|
return 20, "format exact"
|
|
if len(lines) >= 2:
|
|
return 10, f"{len(lines)} lignes au lieu de 3"
|
|
return 0, "format non respecté"
|
|
|
|
|
|
TASKS = [
|
|
("Appel d'outil", _task_tool_call),
|
|
("Code exécutable", _task_code),
|
|
("Consigne courte", _task_instruction),
|
|
("Extraction JSON", _task_json),
|
|
("Respect du format", _task_format),
|
|
]
|
|
|
|
|
|
async def run_bench(model: str) -> AsyncIterator[dict]:
|
|
"""Exécute les 5 épreuves en streamant la progression, stocke le score."""
|
|
total = 0
|
|
details = []
|
|
for name, fn in TASKS:
|
|
yield {"type": "task_start", "task": name}
|
|
task = asyncio.create_task(fn(model))
|
|
try:
|
|
while not task.done():
|
|
done, _ = await asyncio.wait({task}, timeout=10)
|
|
if not done:
|
|
# Empêche OpenResty/Nginx de fermer le SSE pendant une longue
|
|
# génération d'un gros modèle.
|
|
yield {"type": "heartbeat", "task": name}
|
|
score, detail = await task
|
|
except (OllamaError, httpx.HTTPError, OSError) as exc:
|
|
score, detail = 0, f"erreur : {str(exc)[:80]}"
|
|
except Exception as exc:
|
|
# Une épreuve défaillante ne doit pas couper silencieusement le SSE :
|
|
# elle vaut zéro et les autres épreuves continuent.
|
|
score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}"
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
total += score
|
|
details.append({"task": name, "score": score, "detail": detail})
|
|
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
|
|
|
|
results = db.get_config_value(BENCH_KEY) or {}
|
|
results[model] = {"score": total, "details": details, "at": time.time()}
|
|
db.set_config_value(BENCH_KEY, results)
|
|
yield {"type": "done", "score": total, "details": details}
|
|
|
|
|
|
def get_scores() -> dict:
|
|
return db.get_config_value(BENCH_KEY) or {}
|