feat: sanitisation déterministe des codes CIM-10 hors périmètre CPAM

Le LLM (deepseek) propose systématiquement des codes alternatifs (D62,
T81.0, T80, R39.2) malgré l'interdiction dans le prompt. Ces codes
déclenchaient des warnings CRITIQUE → Tier C automatique.

Solution conforme au principe "LLM propose, moteur de règles dispose" :
- _sanitize_unauthorized_codes() supprime les codes hors whitelist du
  texte de la réponse AVANT toute validation
- Nettoyage propre : "D62 — libellé" → "libellé", "(D62)" → ""
- _build_whitelist_prefixes() factorisé en helper partagé
- Sanitisation appliquée après génération ET après correction
- 9 tests unitaires couvrant tous les cas (parenthèses, tirets, multiple)

Résultat live : 0 warning CRITIQUE "code hors périmètre" sur 3 dossiers
(vs 6 warnings CRITIQUE avant). Le seul CRITIQUE restant est le score
adversarial bas, qui reflète des limites de raisonnement du modèle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 15:18:42 +01:00
parent 8e0ed1220d
commit 1844d1be7e
3 changed files with 285 additions and 38 deletions

View File

@@ -28,6 +28,7 @@ from src.control.cpam_response import (
_fuzzy_match_ref,
_get_cim10_definitions,
_get_code_label,
_sanitize_unauthorized_codes,
_search_rag_for_control,
_validate_adversarial,
_validate_codes_in_response,
@@ -2189,3 +2190,132 @@ class TestFuzzyMatchRef:
warnings = _validate_grounding(response_data, tag_map)
assert len(warnings) == 1
assert "Antécédents" in warnings[0]
class TestSanitizeUnauthorizedCodes:
"""Tests pour la sanitisation déterministe des codes CIM-10 hors périmètre."""
def _make_dossier_with_codes(self, dp_code="K81.0", das_codes=None):
das = [Diagnostic(texte=f"DAS {c}", cim10_suggestion=c) for c in (das_codes or [])]
return DossierMedical(
source_file="test.pdf",
diagnostic_principal=Diagnostic(texte="DP test", cim10_suggestion=dp_code),
diagnostics_associes=das,
)
def test_authorized_codes_kept(self):
"""Les codes dans le périmètre ne sont pas modifiés."""
dossier = self._make_dossier_with_codes("K81.0", ["K56.0"])
controle = ControleCPAM(numero_ogc=1, da_ucr="K56.0")
parsed = {
"contre_arguments_medicaux": "Le code K81.0 est justifié par la clinique.",
"conclusion": "Le codage K81.0 et K56.0 est correct.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert len(removed) == 0
assert "K81.0" in parsed["contre_arguments_medicaux"]
assert "K56.0" in parsed["conclusion"]
def test_unauthorized_code_removed_from_text(self):
"""Un code hors périmètre (D62) est supprimé du texte."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"contre_arguments_medicaux": "Le code D62 serait plus approprié que K81.0.",
"conclusion": "Maintenir K81.0.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert "D62" in removed
assert "D62" not in parsed["contre_arguments_medicaux"]
# K81.0 est toujours là
assert "K81.0" in parsed["contre_arguments_medicaux"]
def test_code_with_dash_libelle_cleaned(self):
"""'D62 — Anémie posthémorragique''Anémie posthémorragique'."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"contre_arguments_medicaux": "D62 — Anémie posthémorragique aiguë est plus adapté.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert "D62" in removed
text = parsed["contre_arguments_medicaux"]
assert "D62" not in text
assert "Anémie posthémorragique" in text
def test_code_in_parentheses_cleaned(self):
"""'anémie (D62)''anémie'."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"conclusion": "L'anémie (D62) n'est pas justifiée.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert "D62" in removed
text = parsed["conclusion"]
assert "(D62)" not in text
assert "()" not in text
assert "anémie" in text.lower()
def test_multiple_unauthorized_codes(self):
"""Plusieurs codes hors périmètre sont tous supprimés."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"contre_arguments_medicaux": "D62 et T81.0 et T80 sont des alternatives.",
"conclusion": "K81.0 est maintenu.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert len(removed) == 3
assert "D62" not in parsed["contre_arguments_medicaux"]
assert "T81.0" not in parsed["contre_arguments_medicaux"]
assert "T80" not in parsed["contre_arguments_medicaux"]
def test_preuves_dossier_sanitized(self):
"""Les codes hors périmètre dans preuves_dossier.valeur sont aussi nettoyés."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"preuves_dossier": [
{"ref": "BIO-1", "valeur": "Anémie D62 documentée", "signification": "test"},
],
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert "D62" in removed
assert "D62" not in parsed["preuves_dossier"][0]["valeur"]
def test_no_whitelist_no_sanitization(self):
"""Sans whitelist (pas de codes dans le dossier), aucune sanitisation."""
dossier = DossierMedical(source_file="test.pdf")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"contre_arguments_medicaux": "Le code D62 est pertinent.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert len(removed) == 0
assert "D62" in parsed["contre_arguments_medicaux"]
def test_prefix_match_allows_subcodes(self):
"""K81.09 est autorisé si K81.0 est dans le périmètre (même préfixe K81)."""
dossier = self._make_dossier_with_codes("K81.0")
controle = ControleCPAM(numero_ogc=1)
parsed = {
"contre_arguments_medicaux": "K81.09 est un sous-code valide.",
}
removed = _sanitize_unauthorized_codes(parsed, dossier, controle)
assert len(removed) == 0
assert "K81.09" in parsed["contre_arguments_medicaux"]
def test_validate_codes_after_sanitize_no_warnings(self):
"""Après sanitisation, _validate_codes_in_response ne trouve plus de violations."""
dossier = self._make_dossier_with_codes("K81.0", ["K56.0"])
controle = ControleCPAM(numero_ogc=1, da_ucr="K56.0")
parsed = {
"contre_arguments_medicaux": "D62 et T81.0 sont hors périmètre. K81.0 est correct.",
"conclusion": "Maintenir K81.0.",
}
# Sanitise d'abord
_sanitize_unauthorized_codes(parsed, dossier, controle)
# Puis valide → 0 warning
warnings = _validate_codes_in_response(parsed, dossier, controle)
assert len(warnings) == 0