feat: méthode TIM experte CPAM + moteur de règles étendu

CPAM — Méthode TIM (mémoire en défense) :
- Réécriture CPAM_ARGUMENTATION avec raisonnement 5 passes TIM
  (contexte admin → motif réel → confrontation bio → hiérarchie → validation défensive)
- _BIO_THRESHOLDS (19 entrées) + _build_bio_confrontation() pour
  confrontation biologie/diagnostic avec seuils chiffrés et verdicts
- _format_response() dual format : nouveau TIM (moyens numérotés, tableau
  bio, codes non défendables, conclusion dispositive) + rétrocompat legacy
- CPAM_ADVERSARIAL mis à jour pour vérifier honnêteté intellectuelle
- Tests adaptés + 12 nouveaux tests (bio confrontation, format TIM)

Moteur de règles :
- Nouvelles règles YAML : demographic, diagnostic_conflicts,
  procedure_diagnosis, temporal, parcours
- Bio extraction FAISS (synonymes vectoriels)
- Veto engine enrichi (citations, Trackare skip, règles démographiques)
- Decision engine : _apply_bio_rules_gen() + matchers analytiques

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-03-04 11:57:07 +01:00
parent 795110d2e6
commit ce7a9650af
19 changed files with 1681 additions and 418 deletions

View File

@@ -67,6 +67,24 @@ def dossier_complet() -> DossierMedical:
)
@pytest.fixture
def dossier_trackare_dp() -> DossierMedical:
"""Dossier Trackare : DP pré-codé sans preuve RAG (source d'autorité)."""
return DossierMedical(
source_file="trackare-TEST.pdf",
document_type="trackare",
sejour=Sejour(sexe="F", age=72, duree_sejour=3),
diagnostic_principal=Diagnostic(
texte="Pancréatite aiguë biliaire",
cim10_suggestion="K85.1",
cim10_confidence="high",
source="trackare",
# Pas de source_excerpt, sources_rag, preuves_cliniques
# → c'est normal pour un Trackare
),
)
@pytest.fixture
def controle_cpam() -> ControleCPAM:
"""Contrôle CPAM de test avec codes contestés."""

View File

@@ -19,10 +19,12 @@ from src.config import (
)
from src.control.cpam_response import (
_assess_dossier_strength,
_build_bio_confrontation,
_build_bio_summary,
_build_correction_prompt,
_build_cpam_prompt,
_build_tagged_context,
_BIO_THRESHOLDS,
_check_das_bio_coherence,
_extraction_pass,
_format_response,
@@ -138,14 +140,18 @@ class TestBuildPrompt:
assert "CIM-10 FR 2026" in prompt
assert "page 64" in prompt
def test_prompt_contains_three_axes(self):
def test_prompt_contains_tim_passes(self):
"""Le prompt TIM contient les 5 passes de raisonnement."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "AXE MÉDICAL" in prompt
assert "AXE ASYMÉTRIE D'INFORMATION" in prompt
assert "AXE RÉGLEMENTAIRE" in prompt
assert "PASSE 1" in prompt
assert "PASSE 2" in prompt
assert "PASSE 3" in prompt
assert "PASSE 4" in prompt
assert "PASSE 5" in prompt
assert "MÉMOIRE EN DÉFENSE" in prompt
def test_prompt_contains_traitements_imagerie_when_present(self):
dossier = _make_dossier_complet()
@@ -181,39 +187,44 @@ class TestBuildPrompt:
assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt
def test_prompt_json_format_new_fields(self):
def test_prompt_json_format_tim_fields(self):
"""Le format JSON demandé inclut les champs TIM."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "contre_arguments_medicaux" in prompt
assert "contre_arguments_asymetrie" in prompt
assert "contre_arguments_reglementaires" in prompt
assert "moyens_defense" in prompt
assert "confrontation_bio" in prompt
assert "conclusion_dispositive" in prompt
assert "codes_non_defendables" in prompt
assert "rappel_faits" in prompt
def test_prompt_contains_cite_exacts(self):
"""Le prompt renforcé demande des preuves exactes."""
def test_prompt_contains_honesty_rules(self):
"""Le prompt TIM contient les règles d'honnêteté intellectuelle."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "HONNÊTETÉ INTELLECTUELLE" in prompt
assert "CITE" in prompt
assert "EXACTS" in prompt
assert "JAMAIS" in prompt
def test_prompt_contains_interdiction(self):
"""Le prompt interdit les références inventées."""
def test_prompt_contains_redaction_consignes(self):
"""Le prompt TIM contient les consignes de rédaction numérotées."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "INTERDICTION ABSOLUE" in prompt
assert "MOYENS DE DÉFENSE NUMÉROTÉS" in prompt
assert "N'invente AUCUN tag" in prompt
def test_prompt_contains_preuves_dossier_field(self):
"""Le format JSON demandé inclut preuves_dossier."""
dossier = _make_dossier()
def test_prompt_contains_bio_confrontation(self):
"""Le prompt TIM inclut la section confrontation biologie."""
dossier = _make_dossier_complet()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "preuves_dossier" in prompt
assert "CONFRONTATION BIOLOGIE" in prompt
@patch("src.control.cpam_context.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale"))
@patch("src.control.cpam_context.normalize_code", return_value="K56.0")
@@ -365,6 +376,96 @@ class TestFormatResponse:
assert "AVERTISSEMENT" in text
assert "Manuel Imaginaire 2025" in text
# --- Tests nouveau format TIM ---
def test_tim_format_memoire_defense(self):
"""Le format TIM produit un mémoire en défense structuré."""
parsed = {
"objet": "Contestation DAS — OGC 17 — Mémoire en défense",
"rappel_faits": "Patient M, 65 ans, hospitalisé 5j pour cholécystite aiguë.",
"moyens_defense": [
{
"numero": 1,
"titre": "Le DP K81.0 est justifié par la biologie",
"argument": "CRP à 180 mg/L confirme l'inflammation aiguë.",
"preuves": [
{"ref": "[BIO-1]", "fait": "CRP = 180 mg/L [norme < 5]", "signification": "inflammation sévère"}
],
"source_reglementaire": "[Guide Méthodologique MCO 2026 - p.45] citation",
}
],
"confrontation_bio": [
{"diagnostic": "K81.0", "test": "CRP", "valeur": 180, "seuil": "> 5", "verdict": "CONFIRMÉ"}
],
"asymetrie_information": "Bio non transmise à l'UCR",
"reponse_points_cpam": "La CPAM a raison sur X, mais...",
"codes_non_defendables": [],
"references": [
{"document": "Guide Méthodologique MCO 2026", "page": "45", "citation": "Le DAS doit..."}
],
"conclusion_dispositive": "Par conséquent, nous demandons le MAINTIEN du codage.",
}
text = _format_response(parsed)
assert "MÉMOIRE EN DÉFENSE" in text
assert "RAPPEL DES FAITS" in text
assert "MOYEN N°1" in text
assert "K81.0" in text
assert "Preuve" in text
assert "Source" in text
assert "CONFRONTATION BIOLOGIE" in text
assert "CONFIRMÉ" in text
assert "ASYMÉTRIE D'INFORMATION" in text
assert "RÉPONSE AUX POINTS DE LA CPAM" in text
assert "RÉFÉRENCES RÉGLEMENTAIRES" in text
assert "CONCLUSION" in text
assert "MAINTIEN" in text
def test_tim_format_codes_non_defendables(self):
"""Les codes non défendables apparaissent dans le format TIM."""
parsed = {
"moyens_defense": [],
"codes_non_defendables": [
{"code": "D50.9", "raison": "Hb = 13.5, valeur NORMALE", "recommandation": "Retrait recommandé"}
],
"conclusion_dispositive": "Nous reconnaissons...",
}
text = _format_response(parsed)
assert "CODES NON DÉFENDABLES" in text
assert "D50.9" in text
assert "Retrait recommandé" in text
def test_tim_format_confrontation_table(self):
"""Le tableau de confrontation bio est formaté en grille."""
parsed = {
"moyens_defense": [],
"confrontation_bio": [
{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130", "verdict": "CONFIRMÉ"},
{"diagnostic": "E87.1 HypoNa", "test": "Sodium", "valeur": 138, "seuil": "< 135", "verdict": "NON CONFIRMÉ"},
],
"conclusion_dispositive": "Conclusion...",
}
text = _format_response(parsed)
assert "N17.8" in text
assert "Créatinine" in text
assert "CONFIRMÉ" in text
assert "NON CONFIRMÉ" in text
assert "" in text # table border
def test_tim_retrocompat_legacy_format(self):
"""L'ancien format (sans moyens_defense) utilise le rendu legacy."""
parsed = {
"analyse_contestation": "Analyse...",
"contre_arguments_medicaux": "Arguments médicaux...",
"conclusion": "Conclusion...",
}
text = _format_response(parsed)
assert "CONTRE-ARGUMENTS MÉDICAUX" in text
assert "MÉMOIRE EN DÉFENSE" not in text
class TestValidateReferences:
def test_valid_reference_no_warning(self):
@@ -1061,6 +1162,81 @@ class TestCheckDasBioCoherence:
assert "NORMAL" in prompt
class TestBioConfrontation:
"""Tests pour la confrontation biologie/diagnostic TIM."""
def test_confrontation_with_matching_bio(self):
"""Code avec bio disponible et pathologique → CONFIRMÉ."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="M", age=65),
diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"),
biologie_cle=[
BiologieCle(test="Créatinine", valeur="280 µmol/L", anomalie=True),
],
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
)
result = _build_bio_confrontation(dossier, controle)
assert "N17" in result
assert "Créatinine" in result
assert "280" in result
assert "CONFIRMÉ" in result
def test_confrontation_normal_value(self):
"""Code avec bio NORMALE → NON CONFIRMÉ."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="F", age=70),
diagnostic_principal=Diagnostic(texte="Hyponatrémie", cim10_suggestion="E87.1"),
biologie_cle=[
BiologieCle(test="Sodium", valeur="138 mmol/L", anomalie=False),
],
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
)
result = _build_bio_confrontation(dossier, controle)
assert "E87.1" in result
assert "NON CONFIRMÉ" in result
def test_confrontation_missing_bio(self):
"""Code avec bio absente → NON DISPONIBLE."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="M", age=50),
diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"),
biologie_cle=[],
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
)
result = _build_bio_confrontation(dossier, controle)
assert "NON DISPONIBLE" in result
def test_confrontation_no_threshold(self):
"""Code sans seuil dans _BIO_THRESHOLDS → message par défaut."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(),
diagnostic_principal=Diagnostic(texte="Fracture", cim10_suggestion="S72.0"),
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
)
result = _build_bio_confrontation(dossier, controle)
assert "Aucun seuil" in result
class TestPatientContext:
"""Tests pour le contexte patient dans le prompt."""
@@ -1103,14 +1279,14 @@ class TestPatientContext:
assert "ADMISSION EN URGENCE" in prompt
def test_context_consigne_in_prompt(self):
"""Le prompt contient une consigne sur le contexte clinique."""
"""Le prompt TIM contient les consignes sur le contexte patient."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "CONTEXTE CLINIQUE" in prompt
assert "ÂGE" in prompt
assert "MODE D'ENTRÉE" in prompt
assert "CONTEXTE ADMINISTRATIF" in prompt
assert "pédiatrie" in prompt.lower() or "Pédiatrie" in prompt
assert "urgence" in prompt.lower()
class TestExtractionPass: