On essai de contraindre le modèle utilisé par ollama à répondre dans un certain format et ne plus mettre à l'interieur sa "réflexion"
All checks were successful
All checks were successful
This commit is contained in:
@@ -39,6 +39,16 @@ logger = logging.getLogger(__name__)
|
|||||||
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
||||||
_TEMPERATURE = 0.1
|
_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
|
# 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).
|
# 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.
|
# 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.
|
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 :
|
Règles impératives :
|
||||||
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
|
- 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.
|
- 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 :
|
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
|
||||||
{canonical}
|
{canonical}
|
||||||
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
|
- 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()}
|
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:
|
def _coerce_markdown(value: object) -> str:
|
||||||
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
||||||
|
|
||||||
@@ -283,7 +331,7 @@ class ImportRulesUseCase:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
self._llm, prompt, output_format=_SECTIONS_SCHEMA, temperature=_TEMPERATURE)
|
||||||
except LLMGenerationTimeout:
|
except LLMGenerationTimeout:
|
||||||
# Le modèle générait mais trop lentement pour réécrire tout le morceau
|
# 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).
|
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
|
||||||
@@ -332,4 +380,5 @@ class ImportRulesUseCase:
|
|||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
||||||
return {}, False
|
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
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async def generate_with_retry(
|
|||||||
llm: LLMProvider,
|
llm: LLMProvider,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
|
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
|
||||||
|
|||||||
@@ -24,17 +24,20 @@ class LLMProvider(Protocol):
|
|||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Génère une réponse textuelle à partir d'un prompt donné.
|
"""Génère une réponse textuelle à partir d'un prompt donné.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: le texte envoyé au modèle.
|
prompt: le texte envoyé au modèle.
|
||||||
output_format: contrainte de format optionnelle. Exemple : "json"
|
output_format: contrainte de format optionnelle. "json" pour forcer
|
||||||
pour forcer le modèle à renvoyer du JSON valide. Les
|
un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
|
||||||
fournisseurs qui ne supportent pas une valeur donnée doivent
|
attendue (les fournisseurs qui supportent les sorties
|
||||||
l'ignorer silencieusement ou la traduire au mieux.
|
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) à
|
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
|
||||||
1.0+ (très créatif, hallucine plus facilement). None =
|
1.0+ (très créatif, hallucine plus facilement). None =
|
||||||
valeur par défaut de l'adapter. Recommandation LoreMind :
|
valeur par défaut de l'adapter. Recommandation LoreMind :
|
||||||
|
|||||||
@@ -131,8 +131,10 @@ class GeminiLLMProvider:
|
|||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
||||||
# supprime fences ```json et JSON invalide, principale cause de morceaux ignorés.
|
# supprime fences ```json et JSON invalide, principale cause de morceaux
|
||||||
if output_format == "json":
|
# 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"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ class MistralLLMProvider:
|
|||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
|
# 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),
|
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
|
||||||
# principale cause de morceaux d'import ignorés.
|
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
|
||||||
if output_format == "json":
|
# 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"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class OllamaLLMProvider:
|
|||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
url = f"{self._base_url}/api/generate"
|
url = f"{self._base_url}/api/generate"
|
||||||
@@ -58,6 +58,10 @@ class OllamaLLMProvider:
|
|||||||
"stream": False,
|
"stream": False,
|
||||||
"options": self._build_options(temperature),
|
"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:
|
if output_format is not None:
|
||||||
payload["format"] = output_format
|
payload["format"] = output_format
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user