feat: guardian déterministe + config modèles locaux + prompt TIM R1-R6

Guardian déterministe post-LLM (0 appel modèle, <1ms) :
- Corrige les valeurs bio hallucinées via confrontation dossier
- Step 1b : vérifie l'association test↔diagnostic via _BIO_THRESHOLDS
- Chemin bidirectionnel : CONFIRMÉ↔NON CONFIRMÉ selon bio réelle
- Force R3 : codes bio-infirmés → codes_non_defendables
- Step 2b : retire les codes bio-confirmés de codes_non_defendables
- Retire les moyens défendant des codes bio-contredits
- _safe_bio_replace() : regex protégeant les normes [X-Y]
- Nettoyage texte libre (conclusion, rappel, codes_nd, raisonnement)
- Score factuel déterministe avec pénalités

Config modèles pour déploiement local (DGX Spark) :
- CPAM : mistral-small3.2:24b (TIM complet, bonne précision bio)
- Validation : qwen3:32b (rapide, LOGIC-3 actif)
- Timeout : 120s → 600s pour modèles locaux

Ollama : migration /api/generate → /api/chat (messages format)

Prompt CPAM_ARGUMENTATION restructuré :
- R1-R6 non-négociables en tête (avant données)
- Champ raisonnement_interne (chain-of-thought structuré)
- 5 passes TIM avec références explicites aux règles

Test cpam_quality : métriques guardian dans le résumé

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-03-04 22:00:40 +01:00
parent ce7a9650af
commit 798cee463f
7 changed files with 688 additions and 62 deletions

View File

@@ -30,6 +30,7 @@ from .cpam_validation import (
_build_correction_prompt,
_format_response,
_assess_quality_tier,
_guardian_deterministic,
)
# Backward compat — sera retiré dans un commit futur
@@ -172,6 +173,9 @@ def generate_cpam_response(
logger.info(" CPAM : %d code(s) hors périmètre supprimé(s) : %s",
len(sanitized), ", ".join(sanitized))
# 6b. Gardien déterministe — corrige hallucinations bio, force R3
result = _guardian_deterministic(result, dossier, controle, tag_map)
# 7. Validation des références RAG
ref_warnings = _validate_references(result, sources)
if ref_warnings:
@@ -239,6 +243,7 @@ def generate_cpam_response(
result = corrected
validation = validation2
_sanitize_unauthorized_codes(result, dossier, controle)
result = _guardian_deterministic(result, dossier, controle, tag_map)
ref_warnings = _validate_references(result, sources)
grounding_warnings = _validate_grounding(result, tag_map)
code_warnings = _validate_codes_in_response(result, dossier, controle)

View File

@@ -603,6 +603,350 @@ def _is_new_tim_format(parsed: dict) -> bool:
return "moyens_defense" in parsed
# ---------------------------------------------------------------------------
# Gardien déterministe — validation/correction post-LLM sans appel modèle
# ---------------------------------------------------------------------------
def _guardian_deterministic(
result: dict,
dossier: DossierMedical,
controle: ControleCPAM,
tag_map: dict[str, str],
) -> dict:
"""Gardien déterministe post-LLM : corrige les hallucinations factuelles.
Opérations (0 appel LLM, < 1ms) :
1. Corrige les valeurs bio hallucinées dans confrontation_bio
2. Force la cohérence R3 (bio NON CONFIRMÉ → codes_non_defendables)
3. Retire des moyens_defense les codes bio-contredits
4. Vérifie les tags dans les preuves
5. Calcule un score factuel déterministe
Returns:
dict avec les corrections appliquées + champ "guardian_report" ajouté.
"""
from .cpam_context import _BIO_THRESHOLDS
if not _is_new_tim_format(result):
return result
report: dict = {
"bio_corrections": [],
"codes_moved_to_nd": [],
"preuves_invalid_tags": [],
"score_factuel": 10,
}
penalties = 0
# --- Indexer les valeurs bio réelles du dossier ---
bio_reelles: dict[str, float] = {}
for b in dossier.biologie_cle:
if b.test and b.valeur_num is not None:
bio_reelles[b.test] = b.valeur_num
elif b.test and b.valeur:
try:
bio_reelles[b.test] = float(b.valeur.replace(",", ".").split()[0])
except (ValueError, AttributeError):
pass
# ===== 1. Corriger confrontation_bio =====
confrontation = result.get("confrontation_bio", [])
codes_infirmes: set[str] = set() # codes dont la bio est normale
codes_confirmes: set[str] = set() # codes dont la bio est pathologique
if isinstance(confrontation, list):
for i, entry in enumerate(confrontation):
if not isinstance(entry, dict):
continue
test_name = str(entry.get("test", ""))
valeur_llm = entry.get("valeur")
diagnostic = str(entry.get("diagnostic", ""))
# --- 1b. Vérifier l'association test↔diagnostic via _BIO_THRESHOLDS ---
code_match = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic)
if code_match:
diag_code = code_match.group(1)
# Chercher dans _BIO_THRESHOLDS (préfixe 3 ou 5 chars)
expected = (_BIO_THRESHOLDS.get(diag_code)
or _BIO_THRESHOLDS.get(diag_code[:3]))
if expected:
expected_test = expected["test"].lower()
if expected_test not in test_name.lower() and test_name.lower() not in expected_test:
# Le LLM a associé le mauvais test → corriger
old_test = test_name
entry["test"] = expected["test"]
test_name = expected["test"]
# Chercher la bonne valeur dans le dossier
for bio_key, bio_val in bio_reelles.items():
if expected_test in bio_key.lower() or bio_key.lower() in expected_test:
entry["valeur"] = bio_val
valeur_llm = bio_val
entry["seuil"] = expected.get("condition", "")
break
report["bio_corrections"].append({
"test": old_test,
"correction": f"mauvais test pour {diag_code} : {old_test}{expected['test']}",
"type": "wrong_test_mapping",
})
penalties += 2
logger.info(" Gardien : %s → test corrigé de '%s' vers '%s'",
diag_code, old_test, expected["test"])
# Chercher la valeur réelle dans le dossier
valeur_reelle = None
for bio_key, bio_val in bio_reelles.items():
if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower():
valeur_reelle = bio_val
break
if valeur_reelle is not None:
# Vérifier si le LLM a inventé une valeur différente
try:
v_llm = float(str(valeur_llm).replace(",", ".").split()[0]) if valeur_llm else None
except (ValueError, TypeError):
v_llm = None
if v_llm is not None and abs(v_llm - valeur_reelle) > 0.1:
report["bio_corrections"].append({
"test": test_name,
"valeur_llm": v_llm,
"valeur_reelle": valeur_reelle,
"diagnostic": diagnostic,
})
entry["valeur"] = valeur_reelle
penalties += 2
# Recalculer le verdict avec la valeur réelle
matched_bio_key = None
for bio_key in bio_reelles:
if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower():
matched_bio_key = bio_key
break
if matched_bio_key and matched_bio_key in BIO_NORMALS:
lo, hi = BIO_NORMALS[matched_bio_key]
is_normal = lo <= valeur_reelle <= hi
old_verdict = str(entry.get("verdict", "")).upper()
if is_normal and "CONFIRMÉ" in old_verdict and "NON" not in old_verdict:
# Verdict CONFIRMÉ mais valeur normale → correction
entry["verdict"] = "NON CONFIRMÉ — valeur NORMALE"
report["bio_corrections"].append({
"test": test_name,
"verdict_corrige": "CONFIRMÉ → NON CONFIRMÉ",
"valeur": valeur_reelle,
"norme": f"[{lo}-{hi}]",
})
penalties += 2
elif not is_normal and "NON" in old_verdict and "CONFIRMÉ" in old_verdict:
# Verdict NON CONFIRMÉ mais valeur pathologique → correction inverse
entry["verdict"] = "CONFIRMÉ — valeur PATHOLOGIQUE"
report["bio_corrections"].append({
"test": test_name,
"verdict_corrige": "NON CONFIRMÉ → CONFIRMÉ",
"valeur": valeur_reelle,
"norme": f"[{lo}-{hi}]",
})
penalties += 2
code_match_v = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic)
if is_normal:
# Extraire le code CIM-10 du champ diagnostic
if code_match_v:
codes_infirmes.add(code_match_v.group(1))
elif not is_normal and code_match_v:
# Bio pathologique → ce code est CONFIRMÉ, pas infirmé
codes_confirmes.add(code_match_v.group(1))
# ===== 2. Force R3 : codes infirmés → codes_non_defendables =====
codes_nd = result.get("codes_non_defendables", [])
if not isinstance(codes_nd, list):
codes_nd = []
result["codes_non_defendables"] = codes_nd
codes_nd_existants = {
nd.get("code", "") for nd in codes_nd if isinstance(nd, dict)
}
for code_infirme in codes_infirmes:
if code_infirme not in codes_nd_existants:
# Chercher la valeur bio pour le message
bio_detail = ""
for bio_key, bio_val in bio_reelles.items():
if bio_key in BIO_NORMALS:
lo, hi = BIO_NORMALS[bio_key]
# Vérifier si ce test est lié au code via _BIO_THRESHOLDS
prefix = code_infirme[:3]
threshold = _BIO_THRESHOLDS.get(prefix) or _BIO_THRESHOLDS.get(code_infirme[:5] if len(code_infirme) >= 5 else code_infirme)
if threshold and bio_key.lower() in threshold.get("test", "").lower():
bio_detail = f"{bio_key} = {bio_val} [norme {lo}-{hi}] — valeur NORMALE"
break
is_valid, label = validate_code(normalize_code(code_infirme))
label_str = f" ({label})" if is_valid and label else ""
codes_nd.append({
"code": code_infirme,
"raison": f"{bio_detail or 'Bio normale'}, diagnostic non confirmé biologiquement",
"recommandation": "Retrait recommandé — code indéfendable (gardien déterministe)",
"_source": "guardian",
})
report["codes_moved_to_nd"].append(code_infirme)
penalties += 1
# ===== 2b. Retirer de codes_non_defendables les codes bio-CONFIRMÉS =====
if codes_confirmes and codes_nd:
codes_nd_before = len(codes_nd)
result["codes_non_defendables"] = [
nd for nd in codes_nd
if not isinstance(nd, dict)
or nd.get("code", "") not in codes_confirmes
]
codes_nd = result["codes_non_defendables"]
removed = codes_nd_before - len(codes_nd)
if removed:
report.setdefault("codes_rescued_from_nd", list(codes_confirmes))
logger.info(" Gardien : %d code(s) retirés de non-défendables (bio pathologique) : %s",
removed, ", ".join(codes_confirmes))
# ===== 3. Retirer des moyens_defense les codes bio-contredits =====
moyens = result.get("moyens_defense", [])
if isinstance(moyens, list) and codes_infirmes:
moyens_filtres = []
for moyen in moyens:
if not isinstance(moyen, dict):
moyens_filtres.append(moyen)
continue
titre = str(moyen.get("titre", "")).upper()
argument = str(moyen.get("argument", "")).upper()
moyen_text = titre + " " + argument
# Vérifier si ce moyen défend un code infirmé
defends_infirme = False
for code_inf in codes_infirmes:
if code_inf in moyen_text:
defends_infirme = True
logger.info(" Gardien : moyen retiré (défend %s, bio normale)", code_inf)
penalties += 1
break
if not defends_infirme:
moyens_filtres.append(moyen)
result["moyens_defense"] = moyens_filtres
# Renuméroter
for idx, moyen in enumerate(result["moyens_defense"]):
if isinstance(moyen, dict):
moyen["numero"] = idx + 1
# ===== 4. Vérifier les tags dans les preuves =====
if tag_map:
for moyen in result.get("moyens_defense", []):
if not isinstance(moyen, dict):
continue
for preuve in moyen.get("preuves", []):
if not isinstance(preuve, dict):
continue
ref = str(preuve.get("ref", ""))
# Extraire le tag (ex: [BIO-1] → BIO-1)
tag_match = re.findall(r"\[([A-Z]+-\d+)\]", ref)
for tag in tag_match:
if tag not in tag_map:
report["preuves_invalid_tags"].append(tag)
penalties += 0.5
# ===== 5. Nettoyage des champs texte libre =====
# Remplacer les valeurs bio hallucinées dans les strings (conclusion, rappel, etc.)
text_fields = [
"rappel_faits", "conclusion_dispositive", "reponse_points_cpam",
"asymetrie_information",
]
bio_replacements: list[tuple[str, str]] = [] # (pattern_llm, valeur_reelle)
for corr in report["bio_corrections"]:
v_llm = corr.get("valeur_llm")
v_reelle = corr.get("valeur_reelle")
if v_llm is not None and v_reelle is not None:
fmt_llm = str(int(v_llm)) if v_llm == int(v_llm) else str(v_llm)
fmt_reel = str(int(v_reelle)) if v_reelle == int(v_reelle) else str(v_reelle)
if fmt_llm != fmt_reel:
bio_replacements.append((fmt_llm, fmt_reel))
def _safe_bio_replace(text: str, old: str, new: str) -> str:
"""Remplace une valeur bio en évitant les contextes de norme [X-Y].
Ne remplace PAS si le nombre est précédé de '-' (borne haute d'une norme)
ou suivi de '-' sans espace (borne basse d'une norme).
Utilise des word boundaries pour plus de sécurité.
"""
# Pattern : old_value précédé de word boundary ou espace, pas de tiret collé
pattern = r"(?<![-\d])\b" + re.escape(old) + r"\b(?![-])"
return re.sub(pattern, new, text)
if bio_replacements:
n_replacements = 0
for field in text_fields:
val = result.get(field)
if isinstance(val, str):
for old, new in bio_replacements:
new_val = _safe_bio_replace(val, old, new)
if new_val != val:
n_replacements += 1
val = new_val
result[field] = val
# Aussi dans les arguments des moyens restants
for moyen in result.get("moyens_defense", []):
if isinstance(moyen, dict):
for key in ("titre", "argument"):
txt = moyen.get(key)
if isinstance(txt, str):
for old, new in bio_replacements:
txt = _safe_bio_replace(txt, old, new)
moyen[key] = txt
# Et dans raisonnement_interne
ri = result.get("raisonnement_interne")
if isinstance(ri, dict):
for k, v in ri.items():
if isinstance(v, str):
for old, new in bio_replacements:
v = _safe_bio_replace(v, old, new)
ri[k] = v
# Et dans codes_non_defendables (raison, recommandation)
for nd in result.get("codes_non_defendables", []):
if isinstance(nd, dict):
for key in ("raison", "recommandation"):
txt = nd.get(key)
if isinstance(txt, str):
for old, new in bio_replacements:
txt = _safe_bio_replace(txt, old, new)
nd[key] = txt
report["text_replacements"] = n_replacements
if n_replacements:
logger.info(" Gardien : %d remplacement(s) texte dans champs libres", n_replacements)
# ===== 6. Score factuel déterministe =====
report["score_factuel"] = max(0, round(10 - penalties))
result["guardian_report"] = report
# Log synthèse
n_bio = len(report["bio_corrections"])
n_moved = len(report["codes_moved_to_nd"])
n_tags = len(report["preuves_invalid_tags"])
if n_bio or n_moved or n_tags:
logger.warning(
" Gardien déterministe : %d correction(s) bio, %d code(s) → ND, %d tag(s) invalide(s), score factuel %d/10",
n_bio, n_moved, n_tags, report["score_factuel"],
)
else:
logger.info(" Gardien déterministe : aucune correction nécessaire, score factuel 10/10")
return result
def _format_response(
parsed: dict,
ref_warnings: list[str] | None = None,