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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user