From 0799c850ecc4c31553a9f46cecfecbc1c4414f40 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 11 Jun 2026 15:51:22 +0200 Subject: [PATCH] =?UTF-8?q?On=20essai=20de=20contraindre=20le=20mod=C3=A8l?= =?UTF-8?q?e=20utilis=C3=A9=20par=20ollama=20=C3=A0=20r=C3=A9pondre=20dans?= =?UTF-8?q?=20un=20certain=20format=20et=20ne=20plus=20mettre=20=C3=A0=20l?= =?UTF-8?q?'interieur=20sa=20"r=C3=A9flexion"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/import_rules.py | 57 +++++++++++++++++++-- brain/app/application/llm_retry.py | 2 +- brain/app/domain/ports.py | 13 +++-- brain/app/infrastructure/gemini_adapter.py | 6 ++- brain/app/infrastructure/mistral_adapter.py | 6 ++- brain/app/infrastructure/ollama_adapter.py | 6 ++- 6 files changed, 75 insertions(+), 15 deletions(-) diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index d0c72c3..ab15566 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -39,6 +39,16 @@ logger = logging.getLogger(__name__) # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). _TEMPERATURE = 0.1 +# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à +# Ollama (structured outputs : la grammaire interdit physiquement les objets +# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON… +# indispensable pour les petits modèles locaux qui ne suivent pas les consignes). +# Les adapters cloud le traduisent en mode JSON natif (json_object). +_SECTIONS_SCHEMA: dict = { + "type": "object", + "additionalProperties": {"type": "string"}, +} + # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). # Le modèle reste libre d'en créer d'autres si rien ne correspond. @@ -61,9 +71,13 @@ On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES. +Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} : +{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}} + Règles impératives : -- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. -- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown. +- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après. +- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste). +- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement. - Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond : {canonical} - Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français). @@ -107,6 +121,40 @@ class _SectionMerger: return {title: "\n\n".join(parts) for title, parts in self._merged.items()} +# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement, +# schéma title/content inventé…) : jamais des titres de section voulus. +_META_KEYS = frozenset({ + "thought", "thoughts", "thinking", "reasoning", "raisonnement", + "comment", "commentaire", "commentaires", "note", "notes", "explanation", +}) + + +def _normalize_sections(parsed: dict) -> dict: + """Ramène les formes déviantes courantes au format attendu {titre: contenu}. + + Observé sur les petits modèles locaux (gemma 12b) malgré les consignes : + - enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ; + - schéma inventé {"title": "...", "content": "...", "thought": "..."} → + une seule section dont le titre est la valeur de "title" ; + - clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées. + """ + by_lower = {str(k).strip().lower(): k for k in parsed} + # Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu. + if len(parsed) == 1: + only_key, only_val = next(iter(parsed.items())) + if (isinstance(only_val, dict) + and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}): + return _normalize_sections(only_val) + # Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé. + if "title" in by_lower and "content" in by_lower: + title = str(parsed[by_lower["title"]]).strip() + content = parsed[by_lower["content"]] + if title and not isinstance(content, dict): + return {title: content} + return {k: v for k, v in parsed.items() + if str(k).strip().lower() not in _META_KEYS} + + def _coerce_markdown(value: object) -> str: """Convertit une valeur de section renvoyée par le LLM en markdown plat. @@ -283,7 +331,7 @@ class ImportRulesUseCase: ) try: raw = await generate_with_retry( - self._llm, prompt, output_format="json", temperature=_TEMPERATURE) + self._llm, prompt, output_format=_SECTIONS_SCHEMA, temperature=_TEMPERATURE) except LLMGenerationTimeout: # Le modèle générait mais trop lentement pour réécrire tout le morceau # dans le temps imparti (fréquent sur tier gratuit + gros morceaux). @@ -332,4 +380,5 @@ class ImportRulesUseCase: if not isinstance(parsed, dict): logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) return {}, False - return {str(k): _coerce_markdown(v) for k, v in parsed.items()}, recovered + normalized = _normalize_sections(parsed) + return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered diff --git a/brain/app/application/llm_retry.py b/brain/app/application/llm_retry.py index 6da70b0..773d609 100644 --- a/brain/app/application/llm_retry.py +++ b/brain/app/application/llm_retry.py @@ -60,7 +60,7 @@ async def generate_with_retry( llm: LLMProvider, prompt: str, *, - output_format: str | None = None, + output_format: str | dict | None = None, temperature: float | None = None, ) -> str: """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff). diff --git a/brain/app/domain/ports.py b/brain/app/domain/ports.py index d9407d5..d5da84d 100644 --- a/brain/app/domain/ports.py +++ b/brain/app/domain/ports.py @@ -24,17 +24,20 @@ class LLMProvider(Protocol): self, prompt: str, *, - output_format: str | None = None, + output_format: str | dict | None = None, temperature: float | None = None, ) -> str: """Génère une réponse textuelle à partir d'un prompt donné. Args: prompt: le texte envoyé au modèle. - output_format: contrainte de format optionnelle. Exemple : "json" - pour forcer le modèle à renvoyer du JSON valide. Les - fournisseurs qui ne supportent pas une valeur donnée doivent - l'ignorer silencieusement ou la traduire au mieux. + output_format: contrainte de format optionnelle. "json" pour forcer + un JSON valide ; un dict = SCHÉMA JSON décrivant la structure + attendue (les fournisseurs qui supportent les sorties + structurées — ex. Ollama — contraignent la génération au schéma, + les autres retombent sur leur mode JSON natif). Les fournisseurs + qui ne supportent pas une valeur donnée doivent l'ignorer + silencieusement ou la traduire au mieux. temperature: créativité du modèle, 0.0 (déterministe/factuel) à 1.0+ (très créatif, hallucine plus facilement). None = valeur par défaut de l'adapter. Recommandation LoreMind : diff --git a/brain/app/infrastructure/gemini_adapter.py b/brain/app/infrastructure/gemini_adapter.py index 67f0fba..0a2489c 100644 --- a/brain/app/infrastructure/gemini_adapter.py +++ b/brain/app/infrastructure/gemini_adapter.py @@ -131,8 +131,10 @@ class GeminiLLMProvider: if temperature is not None: body["temperature"] = temperature # Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) : - # supprime fences ```json et JSON invalide, principale cause de morceaux ignorés. - if output_format == "json": + # supprime fences ```json et JSON invalide, principale cause de morceaux + # ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les + # grands modèles cloud respectent la structure demandée par le prompt. + if output_format is not None: body["response_format"] = {"type": "json_object"} async with httpx.AsyncClient(timeout=self._timeout) as client: diff --git a/brain/app/infrastructure/mistral_adapter.py b/brain/app/infrastructure/mistral_adapter.py index 10e9a09..33d5018 100644 --- a/brain/app/infrastructure/mistral_adapter.py +++ b/brain/app/infrastructure/mistral_adapter.py @@ -138,8 +138,10 @@ class MistralLLMProvider: body["temperature"] = temperature # Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences # ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes), - # principale cause de morceaux d'import ignorés. - if output_format == "json": + # principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est + # traduit en json_object : suffisant ici, les grands modèles cloud + # respectent la structure demandée par le prompt. + if output_format is not None: body["response_format"] = {"type": "json_object"} async with httpx.AsyncClient(timeout=self._timeout) as client: diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py index 0cd72a0..2f1261d 100644 --- a/brain/app/infrastructure/ollama_adapter.py +++ b/brain/app/infrastructure/ollama_adapter.py @@ -48,7 +48,7 @@ class OllamaLLMProvider: self, prompt: str, *, - output_format: str | None = None, + output_format: str | dict | None = None, temperature: float | None = None, ) -> str: url = f"{self._base_url}/api/generate" @@ -58,6 +58,10 @@ class OllamaLLMProvider: "stream": False, "options": self._build_options(temperature), } + # "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) : + # Ollama contraint alors la grammaire de génération au schéma — un petit + # modèle local ne PEUT physiquement plus produire d'objets imbriqués, de + # clés "thought" bavardes ou de texte hors JSON. if output_format is not None: payload["format"] = output_format