fix: réparation JSON tronqué + retry 429 + whitelist codes CPAM anti-hallucination

- parse_json_response : réparation JSON tronqué par max_tokens (fermeture
  auto des structures ouvertes), meilleur stripping des blocs fencés avec
  texte superflu après la fermeture ```
- call_ollama : retry avec backoff exponentiel (1s/2s/4s) pour les erreurs
  429 rate limit, 3 tentatives au lieu de 2
- Validation adversariale : max_tokens 800 → 1500
- Prompt CPAM : whitelist PÉRIMÈTRE DE CODES AUTORISÉS (dossier DP+DAS +
  UCR) avec interdiction explicite des codes hors périmètre
- Tests : 19 tests parse_json/_repair_truncated_json, 6 tests whitelist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 13:33:39 +01:00
parent 5d5f119057
commit e77c10da7d
6 changed files with 291 additions and 10 deletions

View File

@@ -511,6 +511,40 @@ def _build_cpam_prompt(
# Définitions CIM-10 déterministes (tous les codes en jeu)
definitions_str = _get_cim10_definitions(dossier, controle)
# Whitelist explicite des codes autorisés (anti-hallucination)
_all_codes: list[str] = []
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
_all_codes.append(dossier.diagnostic_principal.cim10_suggestion)
for das in dossier.diagnostics_associes:
if das.cim10_suggestion:
_all_codes.append(das.cim10_suggestion)
for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr):
if field:
for raw in re.split(r"[,;\s]+", field.strip()):
raw = raw.strip()
if raw:
_all_codes.append(raw)
# Dédupliquer en normalisant
_seen_norm: set[str] = set()
_unique_codes: list[str] = []
for c in _all_codes:
norm = normalize_code(c)
if norm and norm not in _seen_norm:
_seen_norm.add(norm)
is_valid, label = validate_code(norm)
_unique_codes.append(f"{norm}{label}" if is_valid and label else norm)
if _unique_codes:
codes_autorises_str = (
"\nPÉRIMÈTRE DE CODES AUTORISÉS (liste EXHAUSTIVE) :\n"
+ "\n".join(f" {c}" for c in _unique_codes)
+ "\n\nINTERDICTION : Ne mentionne AUCUN code CIM-10 qui ne figure pas "
"dans cette liste. Si un code supplémentaire te semble cliniquement "
"pertinent, signale-le en toutes lettres dans la conclusion SANS "
"citer le code CIM-10."
)
else:
codes_autorises_str = ""
# Contexte clinique tagué pour le grounding
tagged_context, tag_map = _build_tagged_context(dossier)
if tagged_context:
@@ -591,6 +625,7 @@ def _build_cpam_prompt(
decision_ucr=controle.decision_ucr,
codes_str=codes_str,
definitions_str=definitions_str,
codes_autorises_str=codes_autorises_str,
sources_text=sources_text,
extraction_str=extraction_str,
)

View File

@@ -232,9 +232,9 @@ def _validate_adversarial(
)
logger.debug(" Validation adversariale")
result = call_ollama(prompt, temperature=0.0, max_tokens=800, role="validation")
result = call_ollama(prompt, temperature=0.0, max_tokens=1500, role="validation")
if result is None:
result = call_anthropic(prompt, temperature=0.0, max_tokens=800)
result = call_anthropic(prompt, temperature=0.0, max_tokens=1500)
if result is None:
logger.warning(" Validation adversariale échouée — LLM indisponible")
return None

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import logging
import os
import time
import requests
@@ -60,22 +61,85 @@ def call_anthropic(
return None
def _repair_truncated_json(text: str) -> dict | None:
"""Tente de réparer un JSON tronqué (réponse LLM coupée par max_tokens).
Stratégie : fermer les chaînes, tableaux et objets ouverts puis réessayer.
"""
# Étape 1 : détecter si on est dans une chaîne non fermée
in_string = False
escaped = False
for ch in text:
if escaped:
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == '"':
in_string = not in_string
if in_string:
text += '"'
# Étape 2 : compter les ouvreurs/fermeurs non appariés
in_str = False
esc = False
stack: list[str] = []
for ch in text:
if esc:
esc = False
continue
if ch == "\\":
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch in ("{", "["):
stack.append(ch)
elif ch == "}" and stack and stack[-1] == "{":
stack.pop()
elif ch == "]" and stack and stack[-1] == "[":
stack.pop()
# Fermer en ordre inverse
for opener in reversed(stack):
text += "}" if opener == "{" else "]"
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def parse_json_response(raw: str) -> dict | None:
"""Parse une réponse JSON, en gérant les blocs markdown."""
"""Parse une réponse JSON, en gérant les blocs markdown et le JSON tronqué."""
text = raw.strip()
if text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
# Trouver la fermeture ``` (peut être suivie de texte superflu du LLM)
closing_idx = text.find("```")
if closing_idx != -1:
text = text[:closing_idx]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
logger.warning("LLM : JSON invalide : %s", raw[:200])
return None
pass
# Tentative de réparation (JSON tronqué par max_tokens)
repaired = _repair_truncated_json(text)
if repaired is not None:
logger.info("LLM : JSON tronqué réparé (%d chars)", len(text))
return repaired
logger.warning("LLM : JSON invalide : %s", raw[:200])
return None
def call_ollama(
@@ -101,7 +165,7 @@ def call_ollama(
"""
use_model = model or (get_model(role) if role else OLLAMA_MODEL)
use_timeout = timeout or OLLAMA_TIMEOUT
for attempt in range(2):
for attempt in range(3):
try:
response = requests.post(
f"{OLLAMA_URL}/api/generate",
@@ -117,12 +181,19 @@ def call_ollama(
},
timeout=use_timeout,
)
# 429 rate limit → retry avec backoff exponentiel
if response.status_code == 429:
delay = 2 ** attempt # 1s, 2s, 4s
logger.warning("Ollama 429 (rate limit) — retry dans %ds (tentative %d/3)",
delay, attempt + 1)
time.sleep(delay)
continue
response.raise_for_status()
raw = response.json().get("response", "")
result = parse_json_response(raw)
if result is not None:
return result
if attempt == 0:
if attempt < 2:
logger.info("Ollama (%s) : retry après échec de parsing", use_model)
except requests.ConnectionError:
logger.info("Ollama indisponible → fallback Anthropic (%s)", _ANTHROPIC_MODEL)

View File

@@ -14,7 +14,7 @@ Variables par template :
decision_ucr, dp_ucr_line, da_ucr_line
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
arg_ucr, decision_ucr, codes_str, definitions_str,
sources_text, extraction_str
codes_autorises_str, sources_text, extraction_str
CPAM_ADVERSARIAL : response_json, factual_section, normes_section,
dp_ucr_line, da_ucr_line
"""
@@ -247,6 +247,7 @@ DÉCISION UCR : {decision_ucr}
CODES CONTESTÉS :
{codes_str}
{definitions_str}
{codes_autorises_str}
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
{sources_text}