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