Ajout d'un outil de benchmark des modèles d'analyse + support des modèles à raisonnement
- Nouvelle commande `inkflow benchmark` : compare la sortie d'analyse aux fichiers de référence (data/<slug>/reference/), met plusieurs modèles en concurrence, table rich + rapport JSON. Métriques : attribution de locuteur, incises, type/glued. Flags --models, --temperature, --reasoning, --stream, --use-cached + suivi par chapitre. - analysis/benchmark.py : scoring pur (testable) + runner multi-modèles (un MLX à la fois). - gemma.py : support des modèles à raisonnement (retrait de la pensée, désactivation via enable_thinking hors --reasoning, arrêt anticipé sur JSON complet, plafond + température dédiés anti-boucle), récupération du chat_template manquant (fix Mistral), streaming des tokens (set_token_sink). - settings.py : gemma_reasoning, gemma_reasoning_max_tokens, gemma_reasoning_temperature. - Tests : test_benchmark.py (scoring pur), test_gemma_reasoning.py. Conclusion benchmark : Qwen3.6-27B-8bit non-raisonnant = meilleur modèle d'analyse.
This commit is contained in:
@@ -17,6 +17,13 @@ from ..settings import get_settings
|
||||
_JSON_SPAN_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL)
|
||||
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
||||
|
||||
# Marqueurs de FIN de chaine de pensee : on ne garde que ce qui suit le dernier.
|
||||
# - balises type DeepSeek-R1 / Qwen-think
|
||||
# - format a canaux type Gemma 4 / Harmony (la pensee est close par <channel|>)
|
||||
_REASONING_END_MARKERS = ("</think>", "<channel|>", "<|channel|>")
|
||||
# Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer.
|
||||
_REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|<think>)", re.IGNORECASE)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load(model_id: str):
|
||||
@@ -25,6 +32,46 @@ def _load(model_id: str):
|
||||
return load(model_id)
|
||||
|
||||
|
||||
# Hook de streaming optionnel. Si defini, `generate()` diffuse chaque morceau de
|
||||
# texte AU FIL de la generation (pensee comprise, avant tout nettoyage) en
|
||||
# appelant ce callback. Utilise par `inkflow benchmark --stream` pour voir les
|
||||
# tokens en temps reel. None -> generation par lot classique (plus rapide).
|
||||
_TOKEN_SINK: Optional[Any] = None
|
||||
|
||||
|
||||
def set_token_sink(callback) -> None:
|
||||
"""Definit (ou retire avec None) le callback de streaming des tokens."""
|
||||
global _TOKEN_SINK
|
||||
_TOKEN_SINK = callback
|
||||
|
||||
|
||||
def _resolve_chat_template(model_id: str, tokenizer) -> Optional[str]:
|
||||
"""Renvoie un template de chat a passer explicitement, ou None.
|
||||
|
||||
Certaines conversions (Mistral recents...) logent leur template dans un
|
||||
fichier `chat_template.jinja` que le downloader de mlx-lm n'embarque pas
|
||||
toujours : `tokenizer.chat_template` est alors vide et `apply_chat_template`
|
||||
echoue. On recupere alors le fichier officiel du repo. None si le tokenizer
|
||||
possede deja un template (cas courant) ou si aucun n'est disponible.
|
||||
"""
|
||||
if getattr(tokenizer, "chat_template", None):
|
||||
return None
|
||||
from pathlib import Path
|
||||
|
||||
from huggingface_hub import hf_hub_download
|
||||
# Selon les conversions : fichier Jinja brut, ou JSON {"chat_template": ...}.
|
||||
for fname in ("chat_template.jinja", "chat_template.json"):
|
||||
try:
|
||||
text = Path(hf_hub_download(model_id, fname)).read_text(encoding="utf-8")
|
||||
except Exception: # noqa: BLE001 — fichier absent, on tente le suivant
|
||||
continue
|
||||
if fname.endswith(".json"):
|
||||
data = json.loads(text)
|
||||
return data.get("chat_template") if isinstance(data, dict) else None
|
||||
return text
|
||||
return None # aucun template dispo -> apply_chat_template levera une erreur claire
|
||||
|
||||
|
||||
class Gemma:
|
||||
"""Petite facade autour de mlx-lm pour piloter Gemma."""
|
||||
|
||||
@@ -32,10 +79,13 @@ class Gemma:
|
||||
self.model_id = model_id or get_settings().gemma_model
|
||||
self._model = None
|
||||
self._tokenizer = None
|
||||
self._chat_template = None # template recupere si absent du tokenizer
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
if self._model is None:
|
||||
self._model, self._tokenizer = _load(self.model_id)
|
||||
self._chat_template = _resolve_chat_template(
|
||||
self.model_id, self._tokenizer)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
@@ -53,27 +103,76 @@ class Gemma:
|
||||
settings = get_settings()
|
||||
if max_tokens is None:
|
||||
max_tokens = settings.gemma_max_tokens
|
||||
# En mode raisonnement, plafond dedie (garde-fou anti-boucle) ; la
|
||||
# generation s'arrete de toute facon des que le JSON post-pensee est
|
||||
# complet (cf. boucle de streaming ci-dessous).
|
||||
if settings.gemma_reasoning:
|
||||
max_tokens = max(max_tokens, settings.gemma_reasoning_max_tokens)
|
||||
if temperature is None:
|
||||
temperature = settings.gemma_temperature
|
||||
from mlx_lm import generate
|
||||
# Decodage glouton (temp 0) + raisonnement = boucles de pensee sans fin.
|
||||
# On force un echantillonnage minimal en mode raisonnement.
|
||||
if settings.gemma_reasoning and temperature == 0.0:
|
||||
temperature = settings.gemma_reasoning_temperature
|
||||
from mlx_lm.sample_utils import make_sampler
|
||||
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
# Modeles hybrides (Qwen3...) : hors mode raisonnement, on DESACTIVE la
|
||||
# pensee via enable_thinking=False -> JSON direct, bien plus rapide. Avec
|
||||
# --reasoning, on laisse penser puis on retire la pensee en aval. Ce
|
||||
# kwarg est ignore par les templates qui ne l'utilisent pas (Gemma...).
|
||||
template_kwargs = {}
|
||||
if not settings.gemma_reasoning:
|
||||
template_kwargs["enable_thinking"] = False
|
||||
formatted = self._tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt=True, tokenize=False
|
||||
messages, add_generation_prompt=True, tokenize=False,
|
||||
chat_template=self._chat_template, # None -> celui du tokenizer
|
||||
**template_kwargs,
|
||||
)
|
||||
sampler = make_sampler(temp=temperature)
|
||||
return generate(
|
||||
self._model,
|
||||
self._tokenizer,
|
||||
prompt=formatted,
|
||||
max_tokens=max_tokens,
|
||||
sampler=sampler,
|
||||
verbose=False,
|
||||
)
|
||||
# On streame (token par token) si : un sink est branche (--stream) OU on
|
||||
# est en mode raisonnement (pour pouvoir s'arreter des que la reponse est
|
||||
# prete, sans subir les boucles de pensee sans fin). Sinon, lot rapide.
|
||||
if _TOKEN_SINK is not None or settings.gemma_reasoning:
|
||||
from mlx_lm import stream_generate
|
||||
parts = []
|
||||
seen_end = False # marqueur de fin de pensee rencontre
|
||||
for resp in stream_generate(
|
||||
self._model, self._tokenizer, prompt=formatted,
|
||||
max_tokens=max_tokens, sampler=sampler,
|
||||
):
|
||||
parts.append(resp.text)
|
||||
if _TOKEN_SINK is not None:
|
||||
_TOKEN_SINK(resp.text)
|
||||
# Arret anticipe : une fois la pensee close, des que le JSON
|
||||
# post-pensee est complet, inutile de continuer a generer.
|
||||
if settings.gemma_reasoning and ("}" in resp.text or "]" in resp.text):
|
||||
buf = "".join(parts)
|
||||
if not seen_end:
|
||||
seen_end = any(mk in buf for mk in _REASONING_END_MARKERS)
|
||||
if seen_end and _has_complete_json(_strip_reasoning(buf)):
|
||||
break
|
||||
if _TOKEN_SINK is not None:
|
||||
_TOKEN_SINK("\n") # separe les generations successives
|
||||
raw = "".join(parts)
|
||||
else:
|
||||
from mlx_lm import generate
|
||||
raw = generate(
|
||||
self._model,
|
||||
self._tokenizer,
|
||||
prompt=formatted,
|
||||
max_tokens=max_tokens,
|
||||
sampler=sampler,
|
||||
verbose=False,
|
||||
)
|
||||
# Retire la chaine de pensee des modeles a raisonnement (sinon des
|
||||
# fragments de la "pensee" parasitent l'extraction JSON en aval).
|
||||
if settings.gemma_reasoning:
|
||||
return _strip_reasoning(raw)
|
||||
return raw
|
||||
|
||||
def generate_json(
|
||||
self,
|
||||
@@ -101,6 +200,35 @@ class Gemma:
|
||||
raise ValueError(f"Reponse JSON invalide apres {retries + 1} essais: {last_err}")
|
||||
|
||||
|
||||
def _strip_reasoning(text: str) -> str:
|
||||
"""Retire la chaine de pensee d'un modele a raisonnement.
|
||||
|
||||
Ne conserve que ce qui suit le dernier marqueur de fin de pensee
|
||||
(`</think>`, `<channel|>`...). Si seul un marqueur d'ouverture non ferme
|
||||
subsiste (pensee tronquee par le budget de tokens), on le retire en tete
|
||||
pour eviter de parser la pensee a la place de la reponse.
|
||||
"""
|
||||
t = text
|
||||
for marker in _REASONING_END_MARKERS:
|
||||
if marker in t:
|
||||
t = t.rsplit(marker, 1)[-1]
|
||||
t = _REASONING_OPEN_RE.sub("", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _has_complete_json(text: str) -> bool:
|
||||
"""True si `text` contient deja un objet/array JSON complet et parsable.
|
||||
|
||||
Sert a stopper la generation des modeles a raisonnement des que la reponse
|
||||
finale est ecrite (evite de consommer le budget en boucles de pensee).
|
||||
"""
|
||||
try:
|
||||
_extract_json(text)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _extract_json(text: str) -> Any:
|
||||
"""Extrait le premier objet/array JSON d'une reponse libre du modele.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user