"""Helpers agnostiques du moteur : extraction JSON tolerante + retrait de la chaine de pensee des modeles a raisonnement. Module neutre (aucune dependance a un backend) : partage par la facade `LLM` et par les backends (qui s'en servent pour l'arret anticipe en streaming), sans creer de cycle d'import. """ from __future__ import annotations import json import re from typing import Any # Bornes d'un bloc JSON dans une reponse potentiellement bavarde. _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 ) _REASONING_END_MARKERS = ("", "", "<|channel|>") # Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer. _REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|)", re.IGNORECASE) 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 (``, ``...). 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 consumer 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. Tolere le texte parasite avant/apres (y compris un 2e bloc) grace a raw_decode, qui s'arrete au premier JSON complet. """ text = text.strip() fence = _FENCE_RE.search(text) if fence: text = fence.group(1).strip() decoder = json.JSONDecoder() # Cherche le 1er debut de structure JSON et decode a partir de la. for i, ch in enumerate(text): if ch in "[{": try: obj, _ = decoder.raw_decode(text[i:]) return obj except json.JSONDecodeError: continue raise ValueError("aucun JSON trouve dans la reponse")