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

@@ -5,10 +5,24 @@
# === Ollama ===
# OLLAMA_URL=http://localhost:11434
# OLLAMA_MODEL=gemma3:12b
# OLLAMA_MODEL=gemma3:27b-cloud
# OLLAMA_TIMEOUT=120
# OLLAMA_MAX_PARALLEL=2
# === Modèles par rôle LLM ===
# T2A_MODEL_CODING=gemma3:27b-cloud # Codage CIM-10/CCAM, extraction DAS
# T2A_MODEL_CPAM=deepseek-v3.2:cloud # CPAM passe 1 + passe 2
# T2A_MODEL_VALIDATION=deepseek-v3.2:cloud # Validation adversariale
# T2A_MODEL_QC=gemma3:12b # QC batch justifications
#
# IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents
# en production pour que la validation adversariale soit réellement indépendante.
# Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3).
# === Sélecteur DP (NUKE-3) ===
# T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement
# Note : l'ancien nom DP_RANKER_LLM_ENABLED est accepté mais déprécié.
# === Modèles IA ===
# T2A_EMBEDDING_MODEL=dangvantuan/sentence-camembert-large
# T2A_NER_MODEL=Jean-Baptiste/camembert-ner

View File

@@ -1,15 +1,10 @@
version: 2
# Règles biologiques (contradiction bio ⇒ ruled_out)
# + garde-fou "preuve manquante" (diag d'ionogramme sans valeur extraite ⇒ NEED_INFO)
#
# Objectif: éviter des FAIL "bêtes" quand la biologie contredit clairement un diagnostic,
# et éviter des PASS "trop optimistes" quand on n'a même pas la valeur biologique.
#
# Hiérarchie des seuils:
# - Priorité aux normes du document (ex: [N: 135-145])
# - Sinon fallback config/reference_ranges.yaml
# - Si âge inconnu/enfant: safe zones conservatrices (reference_ranges.yaml)
# ------------------------------------------------
# Ces règles permettent d'écarter un diagnostic (status: ruled_out)
# si les preuves biologiques extraites le contredisent formellement.
# Elles génèrent aussi des alertes VETO-17 si la preuve est manquante.
missing_evidence:
enabled: true
@@ -18,17 +13,78 @@ missing_evidence:
score_penalty: 2
rules:
# --- IONOGRAMME ---
hyponatremia:
enabled: true
codes: ["E87.1"] # hyponatrémie
codes: ["E87.1"]
analyte: sodium
threshold_type: low
message: "natrémie normale"
hyperkalemia:
enabled: true
codes: ["E87.5"] # hyperkaliémie
codes: ["E87.5"]
analyte: potassium
threshold_type: high
message: "kaliémie normale"
hypokalemia:
enabled: true
codes: ["E87.6"] # hypokaliémie
codes: ["E87.6"]
analyte: potassium
threshold_type: low
message: "kaliémie normale"
# --- HÉMATOLOGIE ---
anemia:
enabled: true
codes: ["D50", "D50.0", "D50.1", "D50.8", "D50.9", "D51", "D52", "D53", "D55", "D56", "D57", "D58", "D59", "D60", "D61", "D62", "D63", "D64"]
analyte: hemoglobin
threshold_type: low
message: "taux d'hémoglobine normal"
thrombopenia:
enabled: true
codes: ["D69.4", "D69.5", "D69.6"]
analyte: platelets
threshold_type: low
message: "taux de plaquettes normal"
# --- RÉNAL ---
acute_kidney_injury:
enabled: true
codes: ["N17", "N17.0", "N17.1", "N17.2", "N17.8", "N17.9", "N19"]
analyte: creatinine
threshold_type: high
message: "créatininémie normale"
# --- MÉTABOLISME ---
hypoglycemia:
enabled: true
codes: ["E16.0", "E16.1", "E16.2"]
analyte: glucose
threshold_type: low
message: "glycémie normale"
diabetes_uncontrolled:
enabled: true
codes: ["E10.1", "E11.1"] # Avec complications aiguës (acidocétose, etc.)
analyte: hba1c
threshold_type: high
threshold_value: 9.0
message: "HbA1c < 9% (diabète non considéré comme déséquilibré selon HbA1c)"
# --- THYROÏDE ---
hypothyroidism:
enabled: true
codes: ["E02", "E03", "E03.0", "E03.1", "E03.2", "E03.8", "E03.9"]
analyte: tsh
threshold_type: high # TSH élevée = Hypo
message: "TSH normale ou basse"
hyperthyroidism:
enabled: true
codes: ["E05", "E05.0", "E05.1", "E05.2", "E05.8", "E05.9"]
analyte: tsh
threshold_type: low # TSH basse = Hyper
message: "TSH normale ou élevée"

View File

@@ -1,11 +1,11 @@
pdfplumber>=0.10.0
transformers>=4.35.0,<5.0.0
transformers>=4.35.0,<6.0.0
torch>=2.1.0
protobuf>=3.20.0,<4.0.0
protobuf>=3.20.0,<7.0.0
regex>=2023.0
pydantic>=2.5.0
pytest>=7.4.0
sentencepiece>=0.1.99,<0.2.0
sentencepiece>=0.1.99,<0.3.0
edsnlp[ml]>=0.17.0
faiss-cpu>=1.7.0
sentence-transformers>=2.2.0

View File

@@ -28,6 +28,11 @@ CONFIG_DIR = BASE_DIR / "config"
REFERENCE_RANGES_PATH = CONFIG_DIR / "reference_ranges.yaml"
BIO_RULES_PATH = CONFIG_DIR / "bio_rules.yaml"
LAB_SANITY_PATH = CONFIG_DIR / "lab_value_sanity.yaml"
DEMOGRAPHIC_RULES_PATH = CONFIG_DIR / "demographic_rules.yaml"
DIAGNOSTIC_CONFLICTS_PATH = CONFIG_DIR / "diagnostic_conflicts.yaml"
PROCEDURE_DIAGNOSIS_RULES_PATH = CONFIG_DIR / "procedure_diagnosis_rules.yaml"
TEMPORAL_RULES_PATH = CONFIG_DIR / "temporal_rules.yaml"
PARCOURS_RULES_PATH = CONFIG_DIR / "parcours_rules.yaml"
RULES_DIR = CONFIG_DIR / "rules"
RULES_BASE_PATH = RULES_DIR / "base.yaml"
RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml"
@@ -128,6 +133,7 @@ UPLOAD_MAX_SIZE_MB = 50
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
BIO_CONCEPTS_PATH = BASE_DIR / "data" / "bio_concepts.json"
CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json"
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", "/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf"))
@@ -247,6 +253,124 @@ def load_bio_rules() -> Dict[str, Any]:
return defaults
@lru_cache(maxsize=1)
def load_demographic_rules() -> Dict[str, Any]:
"""Charge les règles démographiques (sexe/âge) depuis config/demographic_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"sex_rules": {},
"age_rules": {},
}
path = DEMOGRAPHIC_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
@lru_cache(maxsize=1)
def load_diagnostic_conflicts() -> Dict[str, Any]:
"""Charge les conflits diagnostics depuis config/diagnostic_conflicts.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"mutual_exclusions": [],
"incompatibilities": [],
}
path = DIAGNOSTIC_CONFLICTS_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
@lru_cache(maxsize=1)
def load_procedure_diagnosis_rules() -> Dict[str, Any]:
"""Charge les règles de corrélation actes/diagnostics depuis config/procedure_diagnosis_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"rules": [],
}
path = PROCEDURE_DIAGNOSIS_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
@lru_cache(maxsize=1)
def load_temporal_rules() -> Dict[str, Any]:
"""Charge les règles temporelles depuis config/temporal_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"rules": [],
}
path = TEMPORAL_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
@lru_cache(maxsize=1)
def load_parcours_rules() -> Dict[str, Any]:
"""Charge les règles de parcours patient depuis config/parcours_rules.yaml."""
defaults: Dict[str, Any] = {
"version": 1,
"documentary_rules": {},
"pathway_rules": {},
}
path = PARCOURS_RULES_PATH
if not path.exists():
return defaults
try:
import yaml # type: ignore
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return defaults
merged = dict(defaults)
for k, v in data.items():
merged[k] = v
return merged
except Exception:
return defaults
# --- Garde-fous de parsing des valeurs biologiques (anti-OCR) ---
@@ -827,6 +951,7 @@ class VetoIssue(BaseModel):
severity: str # HARD | MEDIUM | LOW
where: str
message: str
citation: Optional[str] = None
class VetoReport(BaseModel):

View File

@@ -195,6 +195,118 @@ def _build_tagged_context(dossier: DossierMedical) -> tuple[str, dict[str, str]]
return text, tag_map
# ---------------------------------------------------------------------------
# Seuils biologiques par code CIM-10 (Table 8 du référentiel TIM)
# Utilisé pour la confrontation biologie/diagnostic dans le mémoire de défense.
# ---------------------------------------------------------------------------
_BIO_THRESHOLDS: dict[str, dict] = {
"D50": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)", "also": ["Ferritine < 30"]},
"D62": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"},
"D64": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"},
"D69.6": {"test": "Plaquettes", "condition": "< 150 G/L"},
"E03": {"test": "TSH", "condition": "> 4 mUI/L"},
"E05": {"test": "TSH", "condition": "< 0.4 mUI/L"},
"E10": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"},
"E11": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"},
"E87.1": {"test": "Sodium", "condition": "< 135 mmol/L"},
"E87.0": {"test": "Sodium", "condition": "> 145 mmol/L"},
"E87.5": {"test": "Potassium", "condition": "> 5.0 mmol/L"},
"E87.6": {"test": "Potassium", "condition": "< 3.5 mmol/L"},
"K72": {"test": "ASAT", "condition": "> 120 UI/L (3x norme)", "also": ["ALAT > 120"]},
"K85": {"test": "Lipasémie", "condition": "> 180 UI/L (3x norme)"},
"N17": {"test": "Créatinine", "condition": "> 130 (H) / > 110 (F)", "note": "avec élévation aiguë"},
"N18": {"test": "Créatinine", "condition": "DFG calculé"},
"I50": {"test": "BNP", "condition": "> 100 pg/mL ou NT-proBNP > 300"},
"I21": {"test": "Troponine", "condition": "> 0.04 ng/mL"},
"I26": {"test": "D-dimères", "condition": "> 500 ng/mL"},
}
def _build_bio_confrontation(dossier: DossierMedical, controle: ControleCPAM) -> str:
"""Construit le tableau de confrontation biologie/diagnostic pour les codes contestés.
Utilise _BIO_THRESHOLDS (Table 8 TIM) et les valeurs de dossier.biologie_cle
pour produire un verdict par diagnostic : CONFIRMÉ / NON CONFIRMÉ / NON DISPONIBLE.
"""
# Collecter tous les codes en jeu (3 premiers chars pour match préfixe)
codes_in_play: list[str] = []
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
codes_in_play.append(normalize_code(dossier.diagnostic_principal.cim10_suggestion))
for das in dossier.diagnostics_associes:
if das.cim10_suggestion:
codes_in_play.append(normalize_code(das.cim10_suggestion))
for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr):
if not field:
continue
for raw in re.split(r"[,;\s]+", field.strip()):
raw = raw.strip()
if raw:
codes_in_play.append(normalize_code(raw))
if not codes_in_play:
return "(Aucun code en jeu pour la confrontation biologique)"
# Indexer les valeurs bio disponibles
bio_values: dict[str, float] = {}
for b in dossier.biologie_cle:
if b.test and b.valeur:
try:
bio_values[b.test] = float(b.valeur.replace(",", ".").split()[0])
except (ValueError, AttributeError):
pass
lines: list[str] = []
matched_any = False
for code in codes_in_play:
prefix = code[:3] if len(code) >= 3 else code
threshold = _BIO_THRESHOLDS.get(prefix)
if not threshold:
# Essayer avec code complet (ex: E87.1)
threshold = _BIO_THRESHOLDS.get(code[:5] if len(code) >= 5 else code)
if not threshold:
continue
matched_any = True
test_name = threshold["test"]
condition = threshold["condition"]
is_valid, label = validate_code(code)
label_str = f" ({label})" if is_valid and label else ""
if test_name in bio_values:
val = bio_values[test_name]
# Vérifier si la valeur est dans les normes
if test_name in BIO_NORMALS:
lo, hi = BIO_NORMALS[test_name]
is_normal = lo <= val <= hi
verdict = "NON CONFIRMÉ (valeur NORMALE)" if is_normal else "CONFIRMÉ"
else:
verdict = "À VÉRIFIER"
lines.append(
f" {code}{label_str} : {test_name} requis {condition}"
f"valeur dossier = {val}{verdict}"
)
else:
lines.append(
f" {code}{label_str} : {test_name} requis {condition}"
f"NON DISPONIBLE dans le dossier"
)
# Tests complémentaires (also)
for also_test in threshold.get("also", []):
parts = also_test.split()
if len(parts) >= 1:
also_name = parts[0]
if also_name in bio_values:
lines.append(f" + {also_test} → valeur dossier = {bio_values[also_name]}")
else:
lines.append(f" + {also_test} → NON DISPONIBLE")
if not matched_any:
return "(Aucun seuil biologique applicable aux codes en jeu)"
return "\n".join(lines)
# Interprétations cliniques pour le résumé bio déterministe
_BIO_INTERPRETATION: dict[str, dict[str, str]] = {
# --- Hépatique / digestif ---
@@ -648,6 +760,9 @@ def _build_cpam_prompt(
"le manque de données au lieu d'inventer des preuves."
)
# Confrontation biologie / diagnostic (méthode TIM)
bio_confrontation = _build_bio_confrontation(dossier, controle)
# Résumé biologique déterministe (interprétations non modifiables par le LLM)
bio_summary = _build_bio_summary(dossier)
if bio_summary:
@@ -736,5 +851,7 @@ def _build_cpam_prompt(
codes_autorises_str=codes_autorises_str,
sources_text=sources_text,
extraction_str=extraction_str,
bio_confrontation_str=bio_confrontation,
numero_ogc=controle.numero_ogc,
)
return prompt, tag_map

View File

@@ -38,8 +38,10 @@ from .cpam_context import ( # noqa: F401
_get_code_label,
_get_cim10_definitions,
_BIO_INTERPRETATION,
_BIO_THRESHOLDS,
_assess_dossier_strength,
_build_bio_summary,
_build_bio_confrontation,
_check_das_bio_coherence,
)
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401
@@ -120,6 +122,11 @@ def generate_cpam_response(
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
extraction = _extraction_pass(dossier, controle)
degraded_pass1 = extraction is None
if degraded_pass1:
dossier.alertes_codage.append(
"CPAM: passe 1 (extraction structurée) échouée → mode dégradé"
)
# 2. Recherche RAG ciblée
sources = _search_rag_for_control(controle, dossier)
@@ -153,6 +160,12 @@ def generate_cpam_response(
logger.warning(" LLM non disponible — contre-argumentation non générée")
return "", None, rag_sources
# 5b. LOGIC-2 — Marquer le mode dégradé dans le résultat
if degraded_pass1:
result.setdefault("quality_flags", {})
result["quality_flags"]["cpam_pass1_failed"] = True
result["quality_flags"]["degraded_mode"] = True
# 6. Sanitisation déterministe — supprime les codes CIM-10 hors périmètre
sanitized = _sanitize_unauthorized_codes(result, dossier, controle)
if sanitized:
@@ -175,6 +188,16 @@ def generate_cpam_response(
logger.warning(" CPAM : %d code(s) hors périmètre", len(code_warnings))
# 8. Validation adversariale (cohérence factuelle)
# LOGIC-3 : détecter si modèles identiques AVANT l'appel
from ..config import check_adversarial_model_config
same_model, model_msg = check_adversarial_model_config()
if same_model:
result.setdefault("quality_flags", {})
result["quality_flags"]["adversarial_disabled_same_model"] = True
dossier.alertes_codage.append(
"Validation adversariale désactivée (modèles identiques)"
)
adversarial_warnings: list[str] = []
validation = _validate_adversarial(result, tag_map, controle)
if validation and not validation.get("coherent", True):
@@ -186,48 +209,51 @@ def generate_cpam_response(
if adversarial_warnings:
adversarial_warnings.append(f"Score de confiance : {score}/10")
# 8b. Boucle de correction (max 1 retry)
if (validation
and not validation.get("coherent", True)
and validation.get("score_confiance", 10) <= 5
and rule_enabled("RULE-CPAM-CORRECTION-LOOP")):
# 8b. Boucle de correction (max 2 retries)
max_corrections = 2
for attempt in range(max_corrections):
if not (validation
and not validation.get("coherent", True)
and validation.get("score_confiance", 10) <= 5
and rule_enabled("RULE-CPAM-CORRECTION-LOOP")):
break
erreurs_v = validation.get("erreurs", [])
logger.warning(" Score adversarial %s/10 — correction en cours (%d erreur(s))",
validation.get("score_confiance"), len(erreurs_v))
logger.warning(" Score adversarial %s/10 — correction %d/%d (%d erreur(s))",
validation.get("score_confiance"), attempt + 1, max_corrections, len(erreurs_v))
correction_prompt = _build_correction_prompt(prompt, result, validation)
corrected = call_ollama(correction_prompt, temperature=0.0, max_tokens=16000, role="cpam")
if corrected is None:
corrected = call_anthropic(correction_prompt, temperature=0.0, max_tokens=16000)
if corrected:
# Re-valider la correction
validation2 = _validate_adversarial(corrected, tag_map, controle)
score2 = validation2.get("score_confiance", 0) if validation2 else 0
score1 = validation.get("score_confiance", 0)
if not corrected:
break
if score2 > score1:
logger.info(" Correction acceptée (score %s%s)", score1, score2)
result = corrected
validation = validation2
# Sanitiser + recalculer les warnings
_sanitize_unauthorized_codes(result, dossier, controle)
ref_warnings = _validate_references(result, sources)
grounding_warnings = _validate_grounding(result, tag_map)
code_warnings = _validate_codes_in_response(result, dossier, controle)
adversarial_warnings = []
if validation and not validation.get("coherent", True):
for e in validation.get("erreurs", []):
if isinstance(e, str) and e.strip():
adversarial_warnings.append(f"Incohérence détectée : {e}")
if adversarial_warnings:
adversarial_warnings.append(
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
)
else:
logger.warning(" Correction rejetée (score %s%s) — conserve l'original",
score1, score2)
validation2 = _validate_adversarial(corrected, tag_map, controle)
score2 = validation2.get("score_confiance", 0) if validation2 else 0
score1 = validation.get("score_confiance", 0)
if score2 > score1:
logger.info(" Correction %d acceptée (score %s%s)", attempt + 1, score1, score2)
result = corrected
validation = validation2
_sanitize_unauthorized_codes(result, dossier, controle)
ref_warnings = _validate_references(result, sources)
grounding_warnings = _validate_grounding(result, tag_map)
code_warnings = _validate_codes_in_response(result, dossier, controle)
adversarial_warnings = []
if validation and not validation.get("coherent", True):
for e in validation.get("erreurs", []):
if isinstance(e, str) and e.strip():
adversarial_warnings.append(f"Incohérence détectée : {e}")
if adversarial_warnings:
adversarial_warnings.append(
f"Score de confiance : {validation.get('score_confiance', '?')}/10"
)
else:
logger.warning(" Correction %d rejetée (score %s%s)", attempt + 1, score1, score2)
break
all_warnings = ref_warnings + grounding_warnings + code_warnings + adversarial_warnings

View File

@@ -33,7 +33,11 @@ def _fuzzy_match_ref(ref: str, tag_map: dict[str, str]) -> str | None:
def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[str]:
"""Vérifie que les références dans preuves_dossier correspondent à des tags existants.
"""Vérifie que les références dans preuves correspondent à des tags existants.
Supporte les deux formats :
- Ancien : response_data["preuves_dossier"][].ref
- Nouveau TIM : response_data["moyens_defense"][].preuves[].ref
Applique un fuzzy matching par code CIM-10 avant de flaguer un warning.
@@ -44,24 +48,40 @@ def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[st
return []
warnings: list[str] = []
preuves = response_data.get("preuves_dossier")
if not preuves or not isinstance(preuves, list):
return warnings
for p in preuves:
if not isinstance(p, dict):
continue
ref = p.get("ref", "")
def _check_ref(ref: str, context: str) -> None:
if not ref:
continue
if ref not in tag_map:
matched_tag = _fuzzy_match_ref(ref, tag_map)
if matched_tag:
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
continue # pas de warning
valeur = p.get("valeur", "?")
warnings.append(f"Preuve [{ref}] non traçable (« {valeur} »)")
logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref)
return
# Nettoyer les crochets si présents (nouveau format utilise "[BIO-1]")
clean_ref = ref.strip("[]")
if clean_ref in tag_map or ref in tag_map:
return
matched_tag = _fuzzy_match_ref(clean_ref, tag_map)
if matched_tag:
logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag)
return
warnings.append(f"Preuve [{ref}] non traçable (« {context} »)")
logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref)
# Ancien format : preuves_dossier
preuves = response_data.get("preuves_dossier")
if preuves and isinstance(preuves, list):
for p in preuves:
if isinstance(p, dict):
_check_ref(p.get("ref", ""), p.get("valeur", "?"))
# Nouveau format TIM : moyens_defense[].preuves
moyens = response_data.get("moyens_defense")
if moyens and isinstance(moyens, list):
for moyen in moyens:
if not isinstance(moyen, dict):
continue
moyen_preuves = moyen.get("preuves")
if not moyen_preuves or not isinstance(moyen_preuves, list):
continue
for p in moyen_preuves:
if isinstance(p, dict):
_check_ref(p.get("ref", ""), p.get("fait", "?"))
return warnings
@@ -111,12 +131,19 @@ def _validate_references(parsed: dict, sources: list[dict]) -> list[str]:
_CIM10_CODE_RE = re.compile(r"\b([A-Z]\d{2}\.?\d{0,2})\b")
# Champs textuels de la réponse LLM à scanner pour les codes CIM-10
# Supporte les deux formats : ancien (contre_arguments_*) et nouveau (moyens_defense TIM)
_TEXT_FIELDS = (
# Ancien format
"analyse_contestation",
"contre_arguments_medicaux",
"contre_arguments_asymetrie",
"contre_arguments_reglementaires",
"conclusion",
# Nouveau format TIM
"rappel_faits",
"asymetrie_information",
"reponse_points_cpam",
"conclusion_dispositive",
)
@@ -220,7 +247,7 @@ def _sanitize_unauthorized_codes(
if new_val != val:
parsed[key] = new_val
# Sanitiser aussi les preuves_dossier.valeur
# Sanitiser aussi les preuves_dossier.valeur (ancien format)
preuves = parsed.get("preuves_dossier")
if preuves and isinstance(preuves, list):
for p in preuves:
@@ -240,6 +267,27 @@ def _sanitize_unauthorized_codes(
if new_v != v:
p["valeur"] = new_v
# Sanitiser les moyens_defense[].argument (nouveau format TIM)
moyens = parsed.get("moyens_defense")
if moyens and isinstance(moyens, list):
for moyen in moyens:
if not isinstance(moyen, dict):
continue
for field_key in ("argument", "titre"):
val = moyen.get(field_key, "")
if not val or not isinstance(val, str):
continue
new_val = val
for pattern in _SANITIZE_PATTERNS:
new_val = pattern.sub(
lambda m, _p=pattern: _replace_code(m),
new_val,
)
new_val = re.sub(r"\(\s*\)", "", new_val)
new_val = re.sub(r" +", " ", new_val).strip()
if new_val != val:
moyen[field_key] = new_val
if removed:
for code in removed:
norm = normalize_code(code)
@@ -275,7 +323,7 @@ def _validate_codes_in_response(
if val and isinstance(val, str):
text_fields.append(val)
# Preuves du dossier — valeurs
# Preuves du dossier — valeurs (ancien format)
preuves = parsed.get("preuves_dossier")
if preuves and isinstance(preuves, list):
for p in preuves:
@@ -284,6 +332,16 @@ def _validate_codes_in_response(
if v and isinstance(v, str):
text_fields.append(v)
# Moyens de défense (nouveau format TIM)
moyens = parsed.get("moyens_defense")
if moyens and isinstance(moyens, list):
for moyen in moyens:
if isinstance(moyen, dict):
for mkey in ("argument", "titre"):
v = moyen.get(mkey, "")
if v and isinstance(v, str):
text_fields.append(v)
combined_text = "\n".join(text_fields)
found_codes = _CIM10_CODE_RE.findall(combined_text)
@@ -330,6 +388,18 @@ def _validate_adversarial(
"""
import json as _json
# LOGIC-3 — Vérifier si les modèles CPAM et validation sont identiques
from ..config import check_adversarial_model_config
same_model, model_msg = check_adversarial_model_config()
if same_model:
logger.warning("LOGIC-3: %s", model_msg)
return {
"coherent": True,
"erreurs": [f"Validation adversariale dégradée : {model_msg}"],
"score_confiance": 0,
}
# Construire le résumé des éléments factuels disponibles
if tag_map:
factual_lines = "\n".join(f" [{tag}] {content}" for tag, content in tag_map.items())
@@ -341,8 +411,8 @@ def _validate_adversarial(
try:
response_json = _json.dumps(response_data, ensure_ascii=False, indent=None)
# Tronquer si trop long pour le prompt de validation
if len(response_json) > 3000:
response_json = response_json[:3000] + "..."
if len(response_json) > 10000:
response_json = response_json[:10000] + "..."
except (TypeError, ValueError):
logger.warning("Validation adversariale : impossible de sérialiser la réponse")
return None
@@ -365,9 +435,9 @@ def _validate_adversarial(
)
logger.debug(" Validation adversariale")
result = call_ollama(prompt, temperature=0.0, max_tokens=3000, role="validation")
result = call_ollama(prompt, temperature=0.0, max_tokens=6000, role="validation")
if result is None:
result = call_anthropic(prompt, temperature=0.0, max_tokens=3000)
result = call_anthropic(prompt, temperature=0.0, max_tokens=6000)
if result is None:
logger.warning(" Validation adversariale échouée — LLM indisponible")
return None
@@ -407,14 +477,20 @@ def _build_correction_prompt(
erreurs = adversarial_result.get("erreurs", [])
erreurs_text = "\n".join(f" {i}. {e}" for i, e in enumerate(erreurs, 1))
# Résumé compact de la réponse problématique
# Résumé compact de la réponse problématique (supporte les deux formats)
summary_fields = {}
# Ancien format
for key in ("analyse_contestation", "contre_arguments_medicaux",
"contre_arguments_asymetrie", "contre_arguments_reglementaires",
"conclusion"):
val = original_response.get(key)
if val and isinstance(val, str):
# Tronquer chaque champ à 400 chars
summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "")
# Nouveau format TIM
for key in ("rappel_faits", "asymetrie_information", "reponse_points_cpam",
"conclusion_dispositive"):
val = original_response.get(key)
if val and isinstance(val, str):
summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "")
try:
@@ -522,13 +598,182 @@ def _assess_quality_tier(
return tier, requires_review, categorized
def _is_new_tim_format(parsed: dict) -> bool:
"""Détecte si la réponse LLM utilise le nouveau format TIM (moyens_defense)."""
return "moyens_defense" in parsed
def _format_response(
parsed: dict,
ref_warnings: list[str] | None = None,
quality_tier: str | None = None,
categorized_warnings: list[str] | None = None,
) -> str:
"""Formate la réponse LLM en texte lisible."""
"""Formate la réponse LLM en texte lisible.
Supporte deux formats via duck-typing :
- Nouveau TIM : moyens_defense, confrontation_bio, conclusion_dispositive
- Ancien : contre_arguments_medicaux, points_accord, conclusion
"""
if _is_new_tim_format(parsed):
return _format_response_tim(parsed, ref_warnings, quality_tier, categorized_warnings)
return _format_response_legacy(parsed, ref_warnings, quality_tier, categorized_warnings)
def _format_response_tim(
parsed: dict,
ref_warnings: list[str] | None = None,
quality_tier: str | None = None,
categorized_warnings: list[str] | None = None,
) -> str:
"""Formate la réponse LLM au format mémoire en défense TIM."""
sections: list[str] = []
sep = "───────────────────────────────────────────────────────"
sep_heavy = "═══════════════════════════════════════════════════════"
# En-tête
objet = parsed.get("objet", "Mémoire en défense")
sections.append(f"{sep_heavy}\nMÉMOIRE EN DÉFENSE — {objet}\n{sep_heavy}")
# Bandeau qualité si tier C
if quality_tier == "C":
sections.append("⚠ REVUE MANUELLE REQUISE (Qualité : C)")
# Rappel des faits
rappel = parsed.get("rappel_faits")
if rappel:
sections.append(f"RAPPEL DES FAITS\n{rappel}")
sections.append(sep)
# Moyens de défense numérotés
moyens = parsed.get("moyens_defense")
if moyens and isinstance(moyens, list):
for moyen in moyens:
if not isinstance(moyen, dict):
continue
num = moyen.get("numero", "?")
titre = moyen.get("titre", "")
argument = moyen.get("argument", "")
moyen_lines = [f"MOYEN N°{num}{titre}"]
if argument:
moyen_lines.append(argument)
# Preuves intégrées dans chaque moyen
moyen_preuves = moyen.get("preuves")
if moyen_preuves and isinstance(moyen_preuves, list):
for p in moyen_preuves:
if isinstance(p, dict):
ref = p.get("ref", "")
fait = p.get("fait", "")
signif = p.get("signification", "")
moyen_lines.append(f" Preuve : {ref} {fait}{signif}")
# Source réglementaire du moyen
src_regl = moyen.get("source_reglementaire")
if src_regl and src_regl != "null":
moyen_lines.append(f" Source : {src_regl}")
sections.append("\n".join(moyen_lines))
sections.append(sep)
# Confrontation biologie / diagnostic (tableau)
confrontation = parsed.get("confrontation_bio")
if confrontation and isinstance(confrontation, list):
table_lines = ["CONFRONTATION BIOLOGIE / DIAGNOSTIC"]
table_lines.append(
"┌─────────────────┬─────────────┬──────────────┬───────────┬───────────────┐"
)
table_lines.append(
"│ Diagnostic │ Test requis │ Seuil │ Valeur │ Verdict │"
)
table_lines.append(
"├─────────────────┼─────────────┼──────────────┼───────────┼───────────────┤"
)
for row in confrontation:
if not isinstance(row, dict):
continue
diag = str(row.get("diagnostic", ""))[:17].ljust(17)
test = str(row.get("test", ""))[:13].ljust(13)
seuil = str(row.get("seuil", ""))[:14].ljust(14)
valeur = str(row.get("valeur", ""))[:11].ljust(11)
verdict = str(row.get("verdict", ""))[:15].ljust(15)
table_lines.append(f"{diag}{test}{seuil}{valeur}{verdict}")
table_lines.append(
"└─────────────────┴─────────────┴──────────────┴───────────┴───────────────┘"
)
sections.append("\n".join(table_lines))
sections.append(sep)
# Codes non défendables (honnêteté intellectuelle)
codes_nd = parsed.get("codes_non_defendables")
if codes_nd and isinstance(codes_nd, list) and len(codes_nd) > 0:
nd_lines = ["⚠ CODES NON DÉFENDABLES (honnêteté intellectuelle)"]
for nd in codes_nd:
if isinstance(nd, dict):
code = nd.get("code", "?")
raison = nd.get("raison", "")
reco = nd.get("recommandation", "")
nd_lines.append(f"- {code} : {raison}")
if reco:
nd_lines.append(f"{reco}")
sections.append("\n".join(nd_lines))
sections.append(sep)
# Asymétrie d'information
asymetrie = parsed.get("asymetrie_information")
if asymetrie:
sections.append(f"ASYMÉTRIE D'INFORMATION\n{asymetrie}")
sections.append(sep)
# Réponse aux points CPAM
reponse_cpam = parsed.get("reponse_points_cpam")
if reponse_cpam:
sections.append(f"RÉPONSE AUX POINTS DE LA CPAM\n{reponse_cpam}")
sections.append(sep)
# Références réglementaires
refs = parsed.get("references")
if refs:
if isinstance(refs, list):
ref_lines = ["RÉFÉRENCES RÉGLEMENTAIRES"]
for r in refs:
if isinstance(r, dict):
doc = r.get("document", "")
page = r.get("page", "")
citation = r.get("citation", "")
ref_lines.append(f"- [{doc}, p.{page}] {citation}")
else:
ref_lines.append(f"- {r}")
sections.append("\n".join(ref_lines))
else:
sections.append(f"RÉFÉRENCES RÉGLEMENTAIRES\n{refs}")
sections.append(sep_heavy)
# Conclusion dispositive
conclusion = parsed.get("conclusion_dispositive")
if conclusion:
sections.append(f"CONCLUSION\n{conclusion}")
sections.append(sep_heavy)
# Avertissements
sections.extend(_format_warnings(categorized_warnings, ref_warnings))
return "\n\n".join(sections)
def _format_response_legacy(
parsed: dict,
ref_warnings: list[str] | None = None,
quality_tier: str | None = None,
categorized_warnings: list[str] | None = None,
) -> str:
"""Formate la réponse LLM au format hérité (rétro-compatibilité cache)."""
sections = []
# Bandeau qualité si tier C
@@ -543,12 +788,12 @@ def _format_response(
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
sections.append(f"POINTS D'ACCORD\n{accord}")
# Nouveaux champs structurés par axe
# Champs structurés par axe
contre_med = parsed.get("contre_arguments_medicaux")
if contre_med:
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}")
# Preuves du dossier (nouveau champ structuré)
# Preuves du dossier
preuves = parsed.get("preuves_dossier")
if preuves and isinstance(preuves, list):
preuves_lines = []
@@ -577,7 +822,7 @@ def _format_response(
if contre:
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
# Références structurées (nouveau format liste) ou ancien format string
# Références structurées ou ancien format string
refs = parsed.get("references")
if refs:
if isinstance(refs, list):
@@ -599,7 +844,18 @@ def _format_response(
if conclusion:
sections.append(f"CONCLUSION\n{conclusion}")
# Avertissements catégorisés (nouveau format)
# Avertissements
sections.extend(_format_warnings(categorized_warnings, ref_warnings))
return "\n\n".join(sections)
def _format_warnings(
categorized_warnings: list[str] | None = None,
ref_warnings: list[str] | None = None,
) -> list[str]:
"""Formate les avertissements qualité (partagé entre les deux formats)."""
sections: list[str] = []
if categorized_warnings:
critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")]
mineurs = [w for w in categorized_warnings if w.startswith("[MINEUR]")]
@@ -612,8 +868,6 @@ def _format_response(
"AVERTISSEMENTS MINEURS\n" + "\n".join(f"- {w}" for w in mineurs)
)
elif ref_warnings:
# Fallback ancien format
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
return "\n\n".join(sections)
return sections

View File

@@ -4,10 +4,14 @@ from __future__ import annotations
import re
import unicodedata
import logging
import numpy as np
from ..config import BiologieCle, DossierMedical, load_lab_value_sanity
from .bio_normals import BIO_NORMALS, _is_abnormal
logger = logging.getLogger(__name__)
def _norm_key(s: str) -> str:
"""Normalise une clé (minuscules, sans accents) pour index YAML."""
@@ -68,6 +72,100 @@ def _sanitize_bio_value(test_name: str, raw_value: str, sanity_cfg: dict) -> tup
return token, val, quality, reason
def _extract_biologie_faiss(text: str, dossier: DossierMedical) -> None:
"""Extraction biologique via recherche vectorielle FAISS pour les synonymes.
Complète les regex pour les termes non prévus ou les variations complexes.
"""
from .rag_index import get_index
from .rag_search import _get_embed_model
res = get_index(kind="bio")
if not res:
return
faiss_index, metadata = res
try:
model = _get_embed_model()
except Exception as e:
logger.warning("FAISS Bio: modèle d'embedding indisponible (%s)", e)
return
# 1. Découpage du texte en segments glissants (phrases ou groupes de mots)
lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 5]
if not lines:
return
segments = []
for line in lines:
if len(line.split()) > 15:
words = line.split()
for i in range(0, len(words), 10):
segments.append(" ".join(words[i:i+12]))
else:
segments.append(line)
if not segments:
return
# 2. Encodage des segments
try:
embeddings = model.encode(segments, normalize_embeddings=True, show_progress_bar=False)
embeddings = np.array(embeddings, dtype=np.float32)
except Exception as e:
logger.warning("FAISS Bio: erreur encodage segments (%s)", e)
return
# 3. Recherche dans l'index bio
MIN_SCORE_BIO = 0.82
scores, indices = faiss_index.search(embeddings, 1)
sanity_cfg = load_lab_value_sanity()
seen_faiss = set()
for i, (score, idx) in enumerate(zip(scores, indices)):
s = float(score[0])
if s < MIN_SCORE_BIO or idx[0] < 0:
continue
meta = metadata[idx[0]]
concept_name = meta.get("code")
synonym_matched = meta.get("extrait")
segment = segments[i]
# 4. Capture de la valeur numérique
val_match = re.search(r"(?:[=àa:]\s*)?(\d+(?:[.,]\d+)?)\s*(?:[a-zA-Z/%/µ/mm3/G/L/U/I]+)?", segment)
if not val_match:
continue
raw_value = val_match.group(1)
entry_key = (concept_name, raw_value)
if entry_key in seen_faiss:
continue
seen_faiss.add(entry_key)
sanitized = _sanitize_bio_value(concept_name, raw_value, sanity_cfg)
if sanitized:
token, val_num, quality, reason = sanitized
anomalie = _is_abnormal(concept_name, token)
is_dup = any(b.test == concept_name and b.valeur == raw_value for b in dossier.biologie_cle)
if is_dup:
continue
dossier.biologie_cle.append(
BiologieCle(
test=concept_name,
valeur=raw_value,
valeur_num=val_num,
anomalie=anomalie,
quality=quality,
discard_reason=reason,
)
)
logger.debug("FAISS Bio match: %s (%s) = %s dans '%s'", concept_name, synonym_matched, raw_value, segment)
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
"""Extrait des résultats biologiques clés.
@@ -90,12 +188,20 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
# Ionogramme / électrolytes
(r"(?:[Ss]odium|[Nn]atr[ée]mie|(?<![A-Za-z])Na\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9]{2,3}(?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Sodium"),
(r"(?:[Pp]otassium|[Kk]ali[ée]mie|(?<![A-Za-z])K\+?(?![A-Za-z]))\s*[=:àa]?\s*([0-9](?:[.,][0-9]+)?)\s*(?:mmol/L|mEq/L)?", "Potassium"),
(r"(?:[Cc]hlore|[Cc]hlor[ée]mie|(?<![A-Za-z])Cl-?(?![A-Za-z]))\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L)?", "Chlore"),
(r"(?:[Cc]alcium|[Cc]alci[ée]mie|(?<![A-Za-z])Ca\+?(?![A-Za-z]))\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|mg/dL)?", "Calcium"),
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine"),
(r"(?:[Hh][ée]moglobine|\bHb\b)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine"),
(r"\bVGM\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:fL)?", "VGM"),
(r"\bFerritine\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µg/L|ng/mL)?", "Ferritine"),
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Plaquettes"),
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:/mm3|G/L)?", "Leucocytes"),
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine"),
(r"\bUr[ée]e\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Urée"),
(r"(?:[Gg]lyc[ée]mie|[Gg]lucose)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Glycémie"),
(r"\bHbA1c\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:%)?", "HbA1c"),
(r"\bTSH\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mUI/L)?", "TSH"),
]
@@ -182,3 +288,6 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
discard_reason=reason,
)
)
# --- Complément par recherche vectorielle (Synonymes) ---
_extract_biologie_faiss(text, dossier)

View File

@@ -96,6 +96,46 @@ def extract_medical_info(
if use_rag:
_enrich_with_rag(dossier)
# NUKE-3 : sélection DP type DIM (CRH uniquement)
if dossier.document_type != "trackare":
try:
from .dp_selector import select_dp, build_synthese
synthese = build_synthese(dossier, parsed_data)
selection = select_dp(
dossier, synthese, config={"llm_enabled": use_rag},
)
dossier.dp_selection = selection
if selection.chosen_code:
current_code = (
dossier.diagnostic_principal.cim10_suggestion
if dossier.diagnostic_principal else None
)
has_multiple = len(selection.candidates) >= 2
# MAJ DP si :
# - DP existant et NUKE-3 sélectionne un code différent
# - Pas de DP mais plusieurs candidats (choix non trivial)
# Le cas "1 seul candidat, pas de DP" est géré par RULE-DAS-TO-DP
should_update = (
(current_code and selection.chosen_code != current_code)
or (not current_code and has_multiple)
)
if should_update:
dossier.diagnostic_principal = Diagnostic(
texte=selection.chosen_term or "",
cim10_suggestion=selection.chosen_code,
cim10_confidence=selection.confidence,
source="nuke3",
)
if selection.verdict == "REVIEW":
dossier.alertes_codage.append(
f"NUKE-3 REVIEW: DP ambigu — {selection.reason}"
)
except Exception:
logger.warning("NUKE-3: erreur sélection DP", exc_info=True)
# Post-processing : validation des codes CCAM contre le dictionnaire
_validate_ccam(dossier)

View File

@@ -21,7 +21,7 @@ from typing import Optional
import pdfplumber
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, BIO_CONCEPTS_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL
logger = logging.getLogger(__name__)
@@ -112,11 +112,14 @@ def _paths(kind: str) -> tuple[Path, Path]:
kind:
- "ref" : référentiels
- "proc" : procédures
- "bio" : concepts biologiques
- "all" : legacy (faiss.index)
"""
kind = (kind or "ref").lower()
if kind == "proc":
return (RAG_INDEX_DIR / "faiss_proc.index", RAG_INDEX_DIR / "metadata_proc.json")
if kind == "bio":
return (RAG_INDEX_DIR / "faiss_bio.index", RAG_INDEX_DIR / "metadata_bio.json")
if kind == "all":
return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json")
# ref (default)
@@ -470,6 +473,25 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]:
return chunks
def _chunk_bio_concepts() -> list[Chunk]:
"""Génère des chunks à partir de bio_concepts.json pour la recherche sémantique de tests."""
if not BIO_CONCEPTS_PATH.exists():
return []
with open(BIO_CONCEPTS_PATH, encoding="utf-8") as f:
concepts = json.load(f)
chunks = []
for item in concepts:
concept_name = item["concept"]
# On indexe le nom du concept + tous les synonymes
for syn in ([concept_name] + item.get("synonyms", [])):
chunks.append(Chunk(
text=syn,
document="bio_concepts",
code=concept_name, # On stocke le nom du concept "pivot" dans 'code'
))
return chunks
# ---------------------------------------------------------------------------
# Construction de l'index FAISS
# ---------------------------------------------------------------------------
@@ -489,18 +511,24 @@ def build_index(force: bool = False) -> None:
ref_index_path, ref_meta_path = _paths("ref")
proc_index_path, proc_meta_path = _paths("proc")
bio_index_path, bio_meta_path = _paths("bio")
# Si tout existe déjà et pas de force
ref_ok = ref_index_path.exists() and ref_meta_path.exists()
proc_ok = proc_index_path.exists() and proc_meta_path.exists()
bio_ok = bio_index_path.exists() and bio_meta_path.exists()
guide_expected = GUIDE_METHODO_PDF.exists()
if not force and ref_ok and ((not guide_expected) or proc_ok):
if not force and ref_ok and bio_ok and ((not guide_expected) or proc_ok):
logger.info("Index FAISS déjà existants dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR)
return
# Collecter les chunks
ref_chunks: list[Chunk] = []
proc_chunks: list[Chunk] = []
bio_chunks: list[Chunk] = []
# Concepts biologiques
bio_chunks.extend(_chunk_bio_concepts())
# CIM-10 (référentiel)
if CIM10_PDF.exists():
@@ -560,6 +588,7 @@ def build_index(force: bool = False) -> None:
_write_index(ref_chunks, ref_index_path, ref_meta_path, "ref")
_write_index(proc_chunks, proc_index_path, proc_meta_path, "proc")
_write_index(bio_chunks, bio_index_path, bio_meta_path, "bio")
# Invalider les singletons
reset_index()
@@ -569,7 +598,7 @@ def get_index(kind: str = "ref") -> tuple | None:
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).
Args:
kind: "ref" | "proc" | "all".
kind: "ref" | "proc" | "bio" | "all".
Returns:
Tuple (faiss_index, metadata_list) ou None si l'index n'existe pas.
@@ -586,8 +615,8 @@ def get_index(kind: str = "ref") -> tuple | None:
index_path, meta_path = _paths(kind)
# Backwards compat : si ref/proc absent, fallback sur all
if kind in ("ref", "proc") and (not index_path.exists() or not meta_path.exists()):
# Backwards compat : si ref/proc/bio absent, fallback sur all
if kind in ("ref", "proc", "bio") and (not index_path.exists() or not meta_path.exists()):
legacy_idx, legacy_meta = _paths("all")
if legacy_idx.exists() and legacy_meta.exists():
logger.warning("Index %s absent — fallback legacy faiss.index", kind)

View File

@@ -561,7 +561,13 @@ def enrich_diagnostic(
sources = search_similar(diagnostic.texte, top_k=10)
if not sources:
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
# Toujours initialiser sources_rag (même vide) pour traçabilité
diagnostic.sources_rag = []
logger.debug("RAG: 0 résultat FAISS pour « %s »", diagnostic.texte)
# Si un cache hit existe, appliquer le résultat LLM malgré l'absence de sources
if cached is not None:
logger.info("Cache hit (sans sources FAISS) pour %s : « %s »", diag_type.upper(), diagnostic.texte)
_apply_llm_result_diagnostic(diagnostic, cached)
return
# 3. Stocker les sources RAG

View File

@@ -8,6 +8,7 @@ from .templates import (
CPAM_EXTRACTION,
CPAM_ARGUMENTATION,
CPAM_ADVERSARIAL,
DP_RANKER_CONSTRAINED,
)
__all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"CPAM_EXTRACTION",
"CPAM_ARGUMENTATION",
"CPAM_ADVERSARIAL",
"DP_RANKER_CONSTRAINED",
]

View File

@@ -14,9 +14,11 @@ Variables par template :
decision_ucr, dp_ucr_line, da_ucr_line
CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre,
arg_ucr, decision_ucr, codes_str, definitions_str,
codes_autorises_str, sources_text, extraction_str
codes_autorises_str, sources_text, extraction_str,
bio_confrontation_str, numero_ogc
CPAM_ADVERSARIAL : response_json, factual_section, normes_section,
dp_ucr_line, da_ucr_line
DP_RANKER_CONSTRAINED : candidates_str, ctx_str, n_candidates
"""
# ---------------------------------------------------------------------------
@@ -215,96 +217,122 @@ Réponds UNIQUEMENT en JSON :
# ---------------------------------------------------------------------------
# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM
# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM (méthode TIM)
# Source : cpam_response.py _build_cpam_prompt()
# Rôle : cpam | Température : 0.1 | max_tokens : 4000
# Rôle : cpam | Température : 0.1 | max_tokens : 16000
# ---------------------------------------------------------------------------
CPAM_ARGUMENTATION = """\
Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
Tu dois produire une analyse ÉQUILIBRÉE ET CRÉDIBLE de la contestation CPAM, puis contre-argumenter en mobilisant trois axes : médical, asymétrie d'information, et réglementaire.
Tu es un médecin DIM senior expert en contentieux T2A. Tu rédiges un MÉMOIRE EN DÉFENSE \
structuré et argumenté pour répondre à la contestation CPAM ci-dessous.
IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE :
Une contre-argumentation crédible reconnaît TOUJOURS au moins un point valide dans le raisonnement adverse.
Répondre "Aucun point d'accord" décrédibilise l'ensemble de l'argumentation. Tu DOIS identifier au moins un élément où la CPAM a un point légitime (même partiel), puis expliquer pourquoi cela ne suffit pas à invalider le codage.
Ta méthode suit les 5 passes de raisonnement expert TIM :
IMPORTANT — CODES CIM-10 :
Ne parle JAMAIS de « codage initial » ou « codage contesté » sans citer explicitement le code CIM-10 et son libellé (ex: Z45.80 — Ajustement et entretien d'un dispositif implantable).
Chaque argument doit désigner précisément quel code est défendu ou contesté, avec son libellé complet.
PASSE 1 — CONTEXTE ADMINISTRATIF :
Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes) pour cadrer \
ton raisonnement. En pédiatrie (< 18 ans), les normes biologiques et codages diffèrent. \
Une admission en urgence implique un contexte aigu influençant le DP.
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
{dossier_str}
PASSE 2 — MOTIF D'HOSPITALISATION RÉEL :
Distingue le motif d'entrée déclaré du motif réel en te posant :
- Pourquoi CE patient a été hospitalisé CE JOUR (événement déclencheur)
- Quel acte thérapeutique principal a été réalisé
- Le DP retenu est-il cohérent avec cet acte et la durée de séjour
PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC :
Pour chaque diagnostic contesté, confronte aux seuils biologiques :
{bio_confrontation_str}
- Une valeur normale CONTREDIT un diagnostic actif basé sur cette biologie
- Une valeur pathologique SANS diagnostic est un sous-codage potentiel
- CITE les seuils exacts et les valeurs du dossier
PASSE 4 — HIÉRARCHIE DIAGNOSTIQUE :
- Le DP est le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES (pas le plus grave)
- Spécifique exclut générique (K81.0 présent → retirer K81.9)
- Codes R (symptômes) INTERDITS en DP si étiologie identifiée
- Chaque DAS doit répondre OUI à au moins une question :
1. Traitement spécifique pendant ce séjour ?
2. Allongement de la durée de séjour ?
3. Modification de la surveillance ou des examens ?
PASSE 5 — VALIDATION DÉFENSIVE (regard CPAM) :
Pour CHAQUE code défendu, tu DOIS répondre aux 4 questions :
1. Ce diagnostic est-il documenté EXPLICITEMENT dans le dossier, ou DÉDUIT ?
2. Y a-t-il une preuve OBJECTIVE (valeur bio, imagerie, acte CCAM) ?
3. Le code est-il COHÉRENT avec la durée de séjour et les actes réalisés ?
4. Quel DOCUMENT du dossier cite-t-on en premier face à la CPAM ?
DOSSIER MÉDICAL : {dossier_str}
{asymetrie_str}
{tagged_str}
OBJET DU DÉSACCORD : {titre}
CONTESTATION CPAM :
Objet : {titre}
Argument UCR : {arg_ucr}
Décision UCR : {decision_ucr}
ARGUMENTATION DE LA CPAM (UCR) :
{arg_ucr}
DÉCISION UCR : {decision_ucr}
CODES CONTESTÉS :
{codes_str}
CODES EN JEU : {codes_str}
{definitions_str}
{codes_autorises_str}
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
{sources_text}
SOURCES RÉGLEMENTAIRES : {sources_text}
{extraction_str}
CONSIGNES :
RÈGLE ABSOLUE — HONNÊTETÉ INTELLECTUELLE :
Un mémoire crédible ne force JAMAIS un argument que le dossier ne soutient pas.
- Si une valeur biologique est NORMALE alors que le diagnostic l'exige pathologique → \
tu DOIS le signaler et NE PAS défendre ce code sur cet axe
- Si un diagnostic n'a AUCUNE preuve objective (pas de bio, pas d'imagerie, pas d'acte) → \
tu écris : "Ce diagnostic repose sur le seul jugement clinique, sans preuve biologique ou \
paraclinique dans le dossier"
- Si la confrontation bio CONTREDIT un diagnostic → tu NE LE DÉFENDS PAS et tu le signales \
dans le champ "codes_non_defendables"
- Si la CPAM a RAISON sur un point → tu le reconnais clairement. Mieux vaut concéder un \
point indéfendable et gagner en crédibilité sur les points solides
- Principe TIM : "Mieux vaut un code moins précis mais défendable qu'un code précis mais \
indéfendable"
CONTEXTE CLINIQUE :
- Prends en compte l'ÂGE du patient (pédiatrie < 18 ans, personne âgée >= 80 ans), le MODE D'ENTRÉE (urgence vs programmé), et la DURÉE DE SÉJOUR pour contextualiser ton analyse
- En pédiatrie, les normes biologiques et les codages peuvent différer de l'adulte
- Une admission en urgence implique un contexte clinique aigu qui influence le choix du DP
CONSIGNES DE RÉDACTION :
ÉTAPE 1 — ANALYSE HONNÊTE (avant de contre-argumenter) :
- Identifie ce que la CPAM a compris correctement dans le dossier
- Reconnais les points où leur raisonnement est fondé, même partiellement
- Explique ENSUITE pourquoi ces points ne justifient pas leur conclusion
1. STRUCTURE EN MOYENS DE DÉFENSE NUMÉROTÉS (pas de prose libre)
2. Chaque moyen = un argument autonome avec sa preuve FORMELLEMENT DOCUMENTÉE dans le dossier
3. CITE les codes CIM-10 avec libellé complet (ex: N17.8 — Autre insuffisance rénale aiguë)
4. CITE les valeurs bio EXACTES avec seuils normatifs (ex: "CRP = 145 mg/L [norme < 5]")
5. CITE les sources réglementaires au format [Document - page N] "citation verbatim"
6. JAMAIS d'argument sans preuve traçable — si tu n'as pas la preuve, NE FAIS PAS l'argument
7. Ton ASSERTIF mais factuel — pas de formules creuses ("il convient de noter que...")
8. Si un point CPAM est légitime, le reconnaître CLAIREMENT — la crédibilité globale en dépend
9. N'invente AUCUN tag, code ou source qui n'est pas fourni ci-dessus
10. NE JAMAIS qualifier une valeur NORMALE comme pathologique ni extrapoler au-delà des faits
11. Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N]
AXE MÉDICAL :
- Analyse le bien-fondé médical du codage de l'établissement
- CITE les éléments cliniques EXACTS du dossier en utilisant UNIQUEMENT les tags [XX-N] fournis dans la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS
- Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N]
- N'invente JAMAIS un tag qui ne figure pas dans la liste ci-dessus. Si un élément n'a pas de tag, décris-le en texte libre SANS crochets.
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
- Ne mentionne AUCUN élément qui ne figure pas dans les éléments référencés ci-dessus
AXE ASYMÉTRIE D'INFORMATION :
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique
- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni
MISE EN FORME :
- Structure chaque section avec des tirets pour lister les arguments distincts
- Un argument par puce, avec la preuve ou la référence associée
AXE RÉGLEMENTAIRE :
- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle
- Confronte le raisonnement CPAM au texte EXACT des sources fournies
- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent
- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus
- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible"
- Relève les contradictions entre l'argumentation CPAM et les règles officielles
Réponds UNIQUEMENT avec un objet JSON au format suivant :
Réponds UNIQUEMENT avec un objet JSON :
{{
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
"points_accord": "Points CONCRETS où la CPAM a raison ou partiellement raison (JAMAIS 'Aucun' — il y a toujours au moins un point légitime à reconnaître)",
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage, en expliquant pourquoi les points d'accord ne suffisent pas à invalider le codage",
"preuves_dossier": [
{{"ref": "BIO-1 ou DAS-3 ou DP (UNIQUEMENT un tag existant de la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS)", "element": "biologie|imagerie|traitement|acte|diagnostic|antécédent|complication", "valeur": "valeur exacte du dossier", "signification": "explication clinique"}}
"objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense",
"rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue",
"moyens_defense": [
{{
"numero": 1,
"titre": "Titre court du moyen (ex: Le DP N17.8 est justifié par la biologie)",
"argument": "Développement avec preuves tagées [XX-N], valeurs bio avec seuils, sources réglementaires",
"preuves": [
{{"ref": "[BIO-1]", "fait": "Créatinine = 280 µmol/L [norme 50-120]", "signification": "IRA confirmée"}}
],
"source_reglementaire": "[Document - page N] citation verbatim ou null"
}}
],
"confrontation_bio": [
{{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130 µmol/L", "verdict": "CONFIRMÉ"}}
],
"asymetrie_information": "Éléments cliniques que la CPAM n'avait PAS (bio, imagerie, actes) — brièvement",
"reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession si indéfendable",
"codes_non_defendables": [
{{"code": "D50.9", "raison": "Hb = 13.5 g/dL [norme > 12 F] — valeur NORMALE, anémie non confirmée biologiquement", "recommandation": "Retrait recommandé — code indéfendable face à la CPAM"}}
],
"contre_arguments_asymetrie": "Éléments cliniques que la CPAM n'avait pas et qui justifient le codage",
"contre_arguments_reglementaires": "Erreurs d'interprétation réglementaire de la CPAM, avec citations verbatim des sources",
"references": [
{{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}}
],
"conclusion": "Synthèse en citant EXPLICITEMENT les codes CIM-10 défendus (ex: DP Z45.80 — libellé) : points reconnus à la CPAM, puis pourquoi ce codage précis est néanmoins justifié"
"conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (citer les preuves clés), des règles CIM-10 applicables (citer les sources), et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP [CODE — libellé], DAS [CODE — libellé]. [Si code non défendable :] Nous reconnaissons que le code [CODE] ne dispose pas d'un support documentaire suffisant."
}}"""
@@ -315,7 +343,8 @@ Réponds UNIQUEMENT avec un objet JSON au format suivant :
# ---------------------------------------------------------------------------
CPAM_ADVERSARIAL = """\
Tu es un relecteur critique. Vérifie la cohérence de cette contre-argumentation CPAM.
Tu es un relecteur critique expert en codage PMSI. Vérifie la cohérence et l'honnêteté \
intellectuelle de ce mémoire en défense CPAM.
RÉPONSE GÉNÉRÉE :
{response_json}
@@ -329,11 +358,17 @@ CODES CONTESTÉS :
{da_ucr_line}
Vérifie STRICTEMENT :
1. Chaque valeur bio/imagerie/traitement citée dans les preuves existe dans les éléments factuels
2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé)
3. La conclusion est cohérente avec l'argumentation développée
4. Les points d'accord ne contredisent pas les contre-arguments
5. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste
1. Chaque moyen de défense a une preuve traçable FORMELLEMENT documentée dans les éléments factuels
2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est \
RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé)
3. AUCUNE valeur NORMALE n'est présentée comme pathologique
4. La confrontation bio (champ "confrontation_bio") est cohérente avec les valeurs du dossier \
et les seuils normatifs
5. Les codes signalés dans "codes_non_defendables" ne sont PAS défendus dans "moyens_defense"
6. La conclusion dispositive cite les bons codes et reconnaît les concessions
7. Les seuils bio cités correspondent aux normes officielles ci-dessus
8. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste
9. Le champ "reponse_points_cpam" répond factuellement aux arguments CPAM (pas de déni)
Réponds UNIQUEMENT en JSON :
{{
@@ -341,3 +376,36 @@ Réponds UNIQUEMENT en JSON :
"erreurs": ["description précise de chaque incohérence trouvée"],
"score_confiance": 0 à 10
}}"""
# ---------------------------------------------------------------------------
# 8. DP_RANKER_CONSTRAINED — NUKE-3 sélection DP dans une liste fermée
# Source : dp_selector.py _llm_rank()
# Rôle : coding | Température : 0.0 | max_tokens : 1000
# ---------------------------------------------------------------------------
DP_RANKER_CONSTRAINED = """\
Tu es un médecin DIM expert en codage PMSI. Tu dois choisir le Diagnostic Principal (DP) \
parmi la liste FERMÉE de {n_candidates} candidats ci-dessous.
RÈGLES STRICTES :
1. Le DP reflète le MOTIF PRINCIPAL de prise en charge pendant ce séjour
2. Un acte seul (cholécystectomie, biopsie…) NE PEUT PAS être DP s'il existe un candidat textuel
3. Un symptôme (R00-R99) NE PEUT PAS être DP si une étiologie candidate existe dans la liste
4. Une comorbidité chronique (HTA, diabète, BPCO) NE PEUT PAS être DP sauf prise en charge ACTIVE
5. Tu DOIS choisir un index de la liste — JAMAIS de réponse hors liste
CANDIDATS :
{candidates_str}
CONTEXTE CLINIQUE :
{ctx_str}
Réponds UNIQUEMENT en JSON :
{{
"chosen_index": N,
"confidence": "high|medium|low",
"verdict": "CONFIRMED|REVIEW",
"evidence": ["raison 1", "raison 2"],
"reason": "explication courte justifiant le choix"
}}"""

View File

@@ -161,7 +161,6 @@ def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) -
def _is_sodium_test(test: str) -> bool:
t = (test or "").lower().strip()
# 'na' est trop générique: on privilégie sodium/natrémie
if "sodium" in t or "natr" in t:
return True
return bool(re.fullmatch(r"na\+?", t))
@@ -174,6 +173,131 @@ def _is_potassium_test(test: str) -> bool:
return bool(re.fullmatch(r"k\+?", t))
def _get_bio_matcher(analyte: str):
"""Retourne une fonction de matching pour l'analyte demandé."""
a = analyte.lower()
if a == "sodium": return _is_sodium_test
if a == "potassium": return _is_potassium_test
if a == "hemoglobin": return lambda t: "hemoglob" in t.lower() or "hb" in t.lower().split()
if a == "platelets": return lambda t: "plaquette" in t.lower() or "platelet" in t.lower()
if a == "creatinine": return lambda t: "creatinine" in t.lower()
if a == "glucose": return lambda t: "glucose" in t.lower() or "glycemie" in t.lower()
if a == "hba1c": return lambda t: "hba1c" in t.lower()
if a == "tsh": return lambda t: "tsh" in t.lower()
# Fallback: simple inclusion
return lambda t: a in t.lower()
def _apply_bio_rules_gen(dossier: DossierMedical, cfg_ranges: dict) -> None:
"""Applique les règles de validation biologique définies dans config/bio_rules.yaml."""
bio_cfg = load_bio_rules() or {}
rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {}
missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {}
age_band = _age_band(dossier, cfg_ranges)
def _push_need_info_veto(where: str, message: str) -> None:
if dossier.veto_report is None: return
vr = dossier.veto_report
veto = str(missing_cfg.get("veto") or "VETO-17")
if not rule_enabled(veto): return
severity = str(missing_cfg.get("severity") or "LOW")
penalty = int(missing_cfg.get("score_penalty") or 0)
if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])):
return
vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
if (vr.verdict or "") == "PASS": vr.verdict = "NEED_INFO"
if penalty: vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty)
for rule_id, r in rules.items():
if not r.get("enabled", True):
continue
analyte = r.get("analyte")
if not analyte: continue
codes = set(r.get("codes") or [])
matcher = _get_bio_matcher(analyte)
values, lo_doc, hi_doc = _bio_values(dossier, matcher)
t_type = r.get("threshold_type", "low") # 'low' pour hypo/anémie, 'high' pour hyper/insuffisance
# 1) PREUVE MANQUANTE
if not values and bool(missing_cfg.get("enabled", False)):
for i, das in enumerate(dossier.diagnostics_associes or []):
if (das.cim10_suggestion or "") not in codes: continue
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"): continue
rule_key = f"RULE-{rule_id.upper()}-MISSING"
if not rule_enabled(rule_key): continue
reason = f"Preuve manquante: {analyte} non extrait — impossible de valider {das.cim10_suggestion} de façon défendable."
das.status = "needs_info"
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="NEED_INFO",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=reason,
needs_info=[f"Valeur(s) de {analyte} + date(s) ?", "Normes du laboratoire si disponibles ?"],
applied_rules=[rule_key],
)
_push_need_info_veto(f"diagnostics_associes[{i}]", f"{das.cim10_suggestion} suggéré mais aucune preuve de {analyte} n'a été extraite.")
# 2) CONTRADICTION (RULED_OUT)
if values:
is_conflict = False
found_val = 0.0
threshold = 0.0
if t_type == "low":
# Pour un diagnostic de type "Bas" (hypo, anémie), on écarte si la valeur est >= seuil bas normal
threshold = _threshold(cfg_ranges, analyte, age_band, lo_doc)
if min(values) >= threshold:
is_conflict = True
found_val = min(values)
else:
# Pour un diagnostic de type "Haut" (hyper, insuff), on écarte si la valeur est <= seuil haut normal
threshold = _threshold_high(cfg_ranges, analyte, age_band, hi_doc)
if max(values) <= threshold:
is_conflict = True
found_val = max(values)
# Cas particulier : seuil fixe dans le YAML (ex: HbA1c > 9)
if r.get("threshold_value") is not None:
fixed_t = float(r["threshold_value"])
if t_type == "high" and max(values) < fixed_t:
is_conflict = True
found_val = max(values)
threshold = fixed_t
elif t_type == "low" and min(values) > fixed_t:
is_conflict = True
found_val = min(values)
threshold = fixed_t
if is_conflict:
rule_key = f"RULE-{rule_id.upper()}-NORMAL"
if not rule_enabled(rule_key): continue
op = "" if t_type == "low" else ""
reason = f"Contradiction biologique: {analyte}={found_val} ({op}{threshold}, valeur normale) — {r.get('message', 'diagnostic non retenu')}."
for das in dossier.diagnostics_associes or []:
if (das.cim10_suggestion or "") not in codes: continue
das.status = "ruled_out"
das.ruled_out_reason = reason
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="RULED_OUT",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=reason,
needs_info=[
f"Valeurs de {analyte} sur d'autres dates (trend) ?",
f"Mention explicite de {das.cim10_suggestion} confirmée malgré valeurs normales ?",
],
applied_rules=[rule_key],
)
def _bio_values(
dossier: DossierMedical,
matcher,
@@ -369,6 +493,30 @@ def apply_decisions(dossier: DossierMedical) -> None:
for das in dossier.diagnostics_associes or []:
_set_default_final(das)
# --- Règle: nettoyage hiérarchique (VETO-22bis) ---
# Si un code spécifique (ex: K81.0) est présent, on retire le code générique (K81.9)
all_final_codes = set()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_final:
all_final_codes.add(dossier.diagnostic_principal.cim10_final)
for das in dossier.diagnostics_associes or []:
if das.cim10_final:
all_final_codes.add(das.cim10_final)
for das in dossier.diagnostics_associes or []:
if das.cim10_final and das.cim10_final.endswith(".9"):
cat3 = das.cim10_final[:3]
# Chercher s'il existe un autre code plus spécifique dans la même catégorie
if any(c.startswith(cat3) and c != das.cim10_final for c in all_final_codes):
das.status = "removed"
das.cim10_decision = CodeDecision(
action="REMOVE",
final_code=None,
downgraded_from=das.cim10_final,
reason=f"Code générique {das.cim10_final} retiré car un code plus spécifique de la catégorie {cat3} est présent.",
applied_rules=["RULE-HIERARCHY-CLEANUP"],
)
das.cim10_final = None
# --- Règle: D50 sans preuve martiale -> downgrade D64.9 + needs_info ---
if rule_enabled("RULE-D50-NEEDS-IRON"):
for das in dossier.diagnostics_associes or []:
@@ -427,186 +575,9 @@ def apply_decisions(dossier: DossierMedical) -> None:
applied_rules=["RULE-D69.6-PLT-NORMAL"],
)
# --- Pack "bio": contradictions simples Na/K -> ruled_out (piloté par config/bio_rules.yaml)
# Objectif: réduire VETO-09 en écartant les diagnostics "hyper/hypo" quand la valeur est clairement normale.
bio_cfg = load_bio_rules() or {}
rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {}
missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {}
def _push_need_info_veto(where: str, message: str) -> None:
"""Ajoute un VETO non-bloquant quand la preuve biologique est manquante."""
if dossier.veto_report is None:
return
vr = dossier.veto_report
veto = str(missing_cfg.get("veto") or "VETO-17")
# Désactivation globale par YAML (config/rules)
if not rule_enabled(veto):
return
severity = str(missing_cfg.get("severity") or "LOW")
penalty = int(missing_cfg.get("score_penalty") or 0)
# Anti-doublon
if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])):
return
vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
if (vr.verdict or "") == "PASS":
vr.verdict = "NEED_INFO"
if penalty:
vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty)
# Sodium (hyponatrémie)
r = rules.get("hyponatremia") or {}
if r.get("enabled", True):
codes = set(r.get("codes") or ["E87.1"])
na_values, na_lo_doc, _na_hi_doc = _bio_values(dossier, _is_sodium_test)
if (not na_values) and bool(missing_cfg.get("enabled", False)) and rule_enabled("RULE-E87.1-MISSING-NA"):
for i, das in enumerate(dossier.diagnostics_associes or []):
if (das.cim10_suggestion or "") not in codes:
continue
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
continue
reason = "Preuve manquante: natrémie (sodium) non extraite — impossible de valider E87.1 de façon défendable."
where = f"diagnostics_associes[{i}]"
das.status = "needs_info"
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="NEED_INFO",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=reason,
needs_info=[
"Valeur(s) de sodium (natrémie) + date(s) ?",
"Normes du laboratoire si disponibles ?",
],
applied_rules=["RULE-E87.1-MISSING-NA"],
)
_push_need_info_veto(where, "E87.1 suggérée mais aucune natrémie (Na) n'a été extraite des résultats biologiques.")
if na_values and rule_enabled("RULE-E87.1-NA-NORMAL"):
na_threshold = _threshold(cfg_ranges, "sodium", age_band, na_lo_doc)
# Ne ruled_out que si AUCUNE valeur n'est sous la borne basse normale.
if min(na_values) >= na_threshold:
na_val = min(na_values)
for das in dossier.diagnostics_associes or []:
if (das.cim10_suggestion or "") not in codes:
continue
das.status = "ruled_out"
das.ruled_out_reason = (
f"Contradiction biologique: sodium={na_val} (≥{na_threshold}, valeur normale) "
"— hyponatrémie non retenue sans preuve explicite."
)
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="RULED_OUT",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=das.ruled_out_reason,
needs_info=[
"Valeurs de natrémie sur d'autres dates (trend) ?",
"Mention explicite d'hyponatrémie confirmée malgré valeurs normales ?",
"Contexte (perfusions, diurétiques, SIADH, etc.) documenté ?",
],
applied_rules=["RULE-E87.1-NA-NORMAL"],
)
# Potassium (hyper/hypo)
k_values, k_lo_doc, k_hi_doc = _bio_values(dossier, _is_potassium_test)
if (not k_values) and bool(missing_cfg.get("enabled", False)):
# Valeur de kaliémie manquante : on refuse de valider E87.5/E87.6 sans preuve.
codes_hyper = set((rules.get("hyperkalemia") or {}).get("codes") or ["E87.5"])
codes_hypo = set((rules.get("hypokalemia") or {}).get("codes") or ["E87.6"])
codes = codes_hyper.union(codes_hypo)
for i, das in enumerate(dossier.diagnostics_associes or []):
if (das.cim10_suggestion or "") not in codes:
continue
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
continue
code = das.cim10_suggestion or ""
rule_id = f"RULE-{code}-MISSING-K"
if not rule_enabled(rule_id):
continue
reason = f"Preuve manquante: kaliémie (potassium) non extraite — impossible de valider {code} de façon défendable."
where = f"diagnostics_associes[{i}]"
das.status = "needs_info"
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="NEED_INFO",
final_code=None,
downgraded_from=code,
reason=reason,
needs_info=[
"Valeur(s) de potassium (kaliémie) + date(s) ?",
"Normes du laboratoire si disponibles ?",
],
applied_rules=[f"RULE-{code}-MISSING-K"],
)
_push_need_info_veto(where, f"{code} suggéré mais aucune kaliémie (K) n'a été extraite des résultats biologiques.")
if k_values:
# Hyperkaliémie
r = rules.get("hyperkalemia") or {}
if r.get("enabled", True) and rule_enabled("RULE-E87.5-K-NORMAL"):
codes = set(r.get("codes") or ["E87.5"])
k_high = _threshold_high(cfg_ranges, "potassium", age_band, k_hi_doc)
# Ruled_out si AUCUNE valeur ne dépasse la borne haute normale.
if max(k_values) <= k_high:
k_val = max(k_values)
for das in dossier.diagnostics_associes or []:
if (das.cim10_suggestion or "") not in codes:
continue
das.status = "ruled_out"
das.ruled_out_reason = (
f"Contradiction biologique: potassium={k_val} (≤{k_high}, valeur normale) "
"— hyperkaliémie non retenue sans preuve explicite."
)
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="RULED_OUT",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=das.ruled_out_reason,
needs_info=[
"Valeurs de kaliémie sur d'autres dates (trend) ?",
"Mention explicite d'hyperkaliémie confirmée malgré valeurs normales ?",
"Contexte (IRA, IEC/ARA2, spironolactone, hémolyse) documenté ?",
],
applied_rules=["RULE-E87.5-K-NORMAL"],
)
# Hypokaliémie
r = rules.get("hypokalemia") or {}
if r.get("enabled", True) and rule_enabled("RULE-E87.6-K-NORMAL"):
codes = set(r.get("codes") or ["E87.6"])
k_low = _threshold(cfg_ranges, "potassium_low", age_band, k_lo_doc)
# Ruled_out si AUCUNE valeur n'est sous la borne basse normale.
if min(k_values) >= k_low:
k_val = min(k_values)
for das in dossier.diagnostics_associes or []:
if (das.cim10_suggestion or "") not in codes:
continue
das.status = "ruled_out"
das.ruled_out_reason = (
f"Contradiction biologique: potassium={k_val} (≥{k_low}, valeur normale) "
"— hypokaliémie non retenue sans preuve explicite."
)
das.cim10_final = None
das.cim10_decision = CodeDecision(
action="RULED_OUT",
final_code=None,
downgraded_from=das.cim10_suggestion,
reason=das.ruled_out_reason,
needs_info=[
"Valeurs de kaliémie sur d'autres dates (trend) ?",
"Mention explicite d'hypokaliémie confirmée malgré valeurs normales ?",
"Contexte (diurétiques, diarrhées, pertes rénales) documenté ?",
],
applied_rules=["RULE-E87.6-K-NORMAL"],
)
# --- Pack "bio": contradictions pilotées par config/bio_rules.yaml
cfg_ranges = load_reference_ranges()
_apply_bio_rules_gen(dossier, cfg_ranges)
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
if rule_enabled("RULE-DAS-TO-DP"):
@@ -638,6 +609,10 @@ def apply_decisions(dossier: DossierMedical) -> None:
),
)
dossier.diagnostics_associes.remove(best)
# Traçabilité : alerte DIM lisible pour audit
dossier.alertes_codage.append(
f"RULE-DAS-TO-DP: DP absent → DAS {best.cim10_final} ({best.texte}) promu en DP"
)
logger.warning(
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
best.cim10_final, best.texte,

View File

@@ -9,10 +9,13 @@ audit-able, et indépendant des modèles.
from __future__ import annotations
import logging
import re
import unicodedata
from typing import Iterable
logger = logging.getLogger(__name__)
from ..config import (
ActeCCAM,
BiologieCle,
@@ -22,6 +25,11 @@ from ..config import (
VetoReport,
rule_enabled,
rule_force_severity,
load_demographic_rules,
load_diagnostic_conflicts,
load_procedure_diagnosis_rules,
load_temporal_rules,
load_parcours_rules,
)
@@ -221,7 +229,7 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
issues: list[VetoIssue] = []
seen_issue_keys: set[tuple[str, str, str]] = set() # (veto, where, message)
def add(veto: str, severity: str, where: str, message: str):
def add(veto: str, severity: str, where: str, message: str, citation: str | None = None):
# Désactivation globale par YAML (config/rules)
if not rule_enabled(veto):
return
@@ -233,14 +241,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if key in seen_issue_keys:
return
seen_issue_keys.add(key)
issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message, citation=citation))
# -----------------------------
# VETO-02 : code sans preuve
# -----------------------------
dp = dossier.diagnostic_principal
if dp and dp.cim10_suggestion:
if not _has_evidence(dp):
if getattr(dp, "source", None) == "trackare":
# Trackare = codage établissement, source d'autorité : pas de VETO-02
logger.debug("VETO-02 skip: DP %s issu de Trackare (source d'autorité)", dp.cim10_suggestion)
elif not _has_evidence(dp):
add("VETO-02", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} sans preuve exploitable")
for i, das in enumerate(dossier.diagnostics_associes):
@@ -362,6 +373,48 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if das.cim10_suggestion and das.cim10_suggestion.startswith(("N17", "N18", "N19")) and creat < 110 and das.cim10_confidence == "high":
add("VETO-09", "LOW", f"diagnostics_associes[{i}]", f"IR {das.cim10_suggestion} à confirmer (créat={creat})")
# -------------------------------------------------
# VETO-18 & VETO-19 : Règles démographiques (Âge / Sexe)
# -------------------------------------------------
demo_cfg = load_demographic_rules()
patient_sex = (dossier.sejour.sexe or "").upper()
patient_age = dossier.sejour.age
def _check_demo(d: Diagnostic, where: str):
code = str(d.cim10_suggestion or "")
if not code: return
cat3 = code[:3]
# Sexe
for rule_name, rule in demo_cfg.get("sex_rules", {}).items():
if cat3 in rule.get("codes", []):
req_sex = rule.get("required_sex")
if req_sex and patient_sex and patient_sex != req_sex:
add("VETO-19", rule.get("severity", "HARD"), where,
f"Incohérence Sexe: {code} réservé au sexe {req_sex} (patient: {patient_sex})",
citation=rule.get("atih_ref"))
# Âge
for rule_name, rule in demo_cfg.get("age_rules", {}).items():
if cat3 in rule.get("codes", []):
if patient_age is not None:
max_age = rule.get("max_age_years")
min_age = rule.get("min_age_years")
if max_age is not None and patient_age > max_age:
add("VETO-18", rule.get("severity", "HARD"), where,
f"Incohérence Âge: {code} réservé à l'âge ≤ {max_age} ans (patient: {patient_age} ans)",
citation=rule.get("atih_ref"))
if min_age is not None and patient_age < min_age:
add("VETO-18", rule.get("severity", "HARD"), where,
f"Incohérence Âge: {code} réservé à l'âge ≥ {min_age} ans (patient: {patient_age} ans)",
citation=rule.get("atih_ref"))
if dp:
_check_demo(dp, "diagnostic_principal")
for i, das in enumerate(dossier.diagnostics_associes):
if not _is_ruled_out(das):
_check_demo(das, f"diagnostics_associes[{i}]")
# -------------------------------------------------
# VETO-12 : sur-confiance
# -------------------------------------------------
@@ -383,7 +436,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if z3 not in _Z_DP_WHITELIST:
add("VETO-20", "MEDIUM", "diagnostic_principal",
f"DP {dp.cim10_suggestion} est un code Z interdit en DP (catégorie {z3}). "
"Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).")
"Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).",
citation="Guide Méthodologique MCO : Règles de sélection du Diagnostic Principal (Chapitre Z)")
# -------------------------------------------------
# VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible
@@ -399,7 +453,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
severity = "LOW" if has_precise else "MEDIUM"
add("VETO-21", severity, "diagnostic_principal",
f"DP {dp.cim10_suggestion} est un code symptôme (chapitre R) → CMD 23. "
"Un diagnostic étiologique précis devrait être recherché comme DP.")
"Un diagnostic étiologique précis devrait être recherché comme DP.",
citation="Guide Méthodologique MCO : Sélection du DP et codes de symptômes (Chapitre XVIII)")
# -------------------------------------------------
# VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS
@@ -415,26 +470,39 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion:
add("VETO-22", "LOW", f"diagnostics_associes[{i}]",
f"DAS {das.cim10_suggestion} même catégorie que DP {dp.cim10_suggestion} "
f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.")
f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.",
citation="Guide Méthodologique MCO : Règle de non-redondance du codage")
# -------------------------------------------------
# VETO-23 : Exclusions mutuelles (diabète type 1 vs type 2, HTA)
# Règle PMSI : codes incompatibles dans le même séjour.
# VETO-25 & VETO-26 : Cohérence inter-diagnostics (conflits)
# -------------------------------------------------
all_codes = set()
conflict_cfg = load_diagnostic_conflicts()
codes_full = set()
if dp and dp.cim10_suggestion:
all_codes.add(str(dp.cim10_suggestion)[:3])
codes_full.add(str(dp.cim10_suggestion))
for das in dossier.diagnostics_associes:
if not _is_ruled_out(das) and das.cim10_suggestion:
all_codes.add(str(das.cim10_suggestion)[:3])
codes_full.add(str(das.cim10_suggestion))
# Exclusions mutuelles
for rule in conflict_cfg.get("mutual_exclusions", []):
matches = [c for c in codes_full if any(c.startswith(prefix) for prefix in rule.get("codes", []))]
if len(set(c[:3] for c in matches)) > 1:
add("VETO-25", rule.get("severity", "MEDIUM"), "diagnostics_associes",
f"{rule.get('name')}: {rule.get('message')} ({', '.join(matches)})",
citation=rule.get("atih_ref"))
_MUTUAL_EXCLUSIONS = [
({"E10"}, {"E11"}, "Diabète type 1 (E10) et type 2 (E11) mutuellement exclusifs"),
({"I10"}, {"I11", "I12", "I13"}, "HTA essentielle (I10) incompatible avec HTA secondaire (I11/I12/I13)"),
]
for group_a, group_b, msg in _MUTUAL_EXCLUSIONS:
if (all_codes & group_a) and (all_codes & group_b):
add("VETO-23", "MEDIUM", "diagnostics_associes", msg)
# Incompatibilités
for rule in conflict_cfg.get("incompatibilities", []):
pair = rule.get("pair", [])
if len(pair) >= 2:
p1_prefixes = [pair[0]] if isinstance(pair[0], str) else pair[0]
p2_prefixes = pair[1:]
part1 = any(any(c.startswith(p) for p in p1_prefixes) for c in codes_full)
part2 = any(any(c.startswith(p) for p in p2_prefixes) for c in codes_full)
if part1 and part2:
add("VETO-26", rule.get("severity", "HARD"), "diagnostics_associes", rule.get("message"),
citation=rule.get("atih_ref"))
# -------------------------------------------------
# VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
@@ -443,13 +511,125 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
# -------------------------------------------------
has_injury = any(
str(c).startswith(("S", "T")) and not str(c).startswith(("T80", "T81", "T82", "T83", "T84", "T85", "T86", "T87", "T88"))
for c in all_codes
for c in codes_full
)
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in all_codes)
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in codes_full)
if has_injury and not has_external:
add("VETO-24", "LOW", "diagnostics_associes",
"Lésion traumatique (S/T) sans code de cause externe (V/W/X/Y). "
"La réglementation PMSI exige un code de circonstance pour les traumatismes.")
"La réglementation PMSI exige un code de circonstance pour les traumatismes.",
citation="Guide Méthodologique MCO : Codage des lésions et causes externes (Chapitre XIX/XX)")
# -------------------------------------------------
# VETO-27 : Corrélation Actes CCAM / Diagnostics CIM-10
# -------------------------------------------------
proc_diag_cfg = load_procedure_diagnosis_rules()
actes_codes = {str(a.code_ccam_suggestion) for a in dossier.actes_ccam if a.code_ccam_suggestion}
diags_codes = set()
if dp and dp.cim10_suggestion:
diags_codes.add(str(dp.cim10_suggestion))
for das in dossier.diagnostics_associes:
if not _is_ruled_out(das) and das.cim10_suggestion:
diags_codes.add(str(das.cim10_suggestion))
for rule in proc_diag_cfg.get("rules", []):
patterns = rule.get("procedure_patterns", [])
matching_actes = [ac for ac in actes_codes if any(ac.startswith(p) for p in patterns)]
if matching_actes:
required = rule.get("required_diagnosis", [])
has_justification = any(any(dc.startswith(r) for r in required) for dc in diags_codes)
if not has_justification:
for i, acte in enumerate(dossier.actes_ccam):
if str(acte.code_ccam_suggestion) in matching_actes:
add("VETO-27", rule.get("severity", "HARD"), f"actes_ccam[{i}]",
f"Acte {acte.code_ccam_suggestion} ({rule.get('name')}) sans diagnostic justificatif requis ({', '.join(required)})",
citation=rule.get("atih_ref"))
# -------------------------------------------------
# VETO-28 : Validation Temporelle (Durée de séjour)
# -------------------------------------------------
temporal_cfg = load_temporal_rules()
duree_reelle = dossier.sejour.duree_sejour
if duree_reelle is not None:
# Collecter tous les codes du séjour
all_stay_codes = set()
if dp and dp.cim10_suggestion: all_stay_codes.add(str(dp.cim10_suggestion))
for d in dossier.diagnostics_associes:
if not _is_ruled_out(d) and d.cim10_suggestion: all_stay_codes.add(str(d.cim10_suggestion))
for a in dossier.actes_ccam:
if a.code_ccam_suggestion: all_stay_codes.add(str(a.code_ccam_suggestion))
for rule in temporal_cfg.get("rules", []):
patterns = rule.get("codes", [])
# Vérifier si un des codes de la règle est présent dans le dossier
if any(any(c.startswith(p) for p in patterns) for c in all_stay_codes):
min_d = rule.get("min_stay_days")
max_d = rule.get("max_stay_days")
if min_d is not None and duree_reelle < min_d:
add("VETO-28", rule.get("severity", "MEDIUM"), "sejour",
f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu min: {min_d}j)",
citation=rule.get("atih_ref"))
if max_d is not None and duree_reelle > max_d:
add("VETO-28", rule.get("severity", "LOW"), "sejour",
f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu max: {max_d}j)",
citation=rule.get("atih_ref"))
# -------------------------------------------------
# VETO-29, 30, 31 : Parcours Patient et Inventaire (Expertise DIM)
# -------------------------------------------------
parcours_cfg = load_parcours_rules()
# VETO-29 : Inventaire des pièces
doc_rules = parcours_cfg.get("documentary_rules", {}).get("required_documents", [])
current_doc_types = {dossier.document_type.lower()}
# En cas de dossier fusionné, on peut avoir plusieurs types dans source_files (heuristique)
for sf in (dossier.source_files or []):
if "CRO" in sf.upper(): current_doc_types.add("cro")
if "ANAPATH" in sf.upper(): current_doc_types.add("anapath")
if "CRH" in sf.upper(): current_doc_types.add("crh")
for rule in doc_rules:
# Condition sur les actes
if rule.get("if_has_procedure_prefix"):
has_proc = any(any(str(a.code_ccam_suggestion).startswith(p) for p in rule["if_has_procedure_prefix"])
for a in dossier.actes_ccam if a.code_ccam_suggestion)
if has_proc:
if not any(req in current_doc_types for req in rule.get("require_one_of", [])):
add("VETO-29", rule.get("severity", "HARD"), "Global", rule.get("message"), citation=rule.get("atih_ref"))
# Condition sur les diagnostics
if rule.get("if_has_diagnosis_prefix"):
has_diag = any(any(str(c).startswith(p) for p in rule["if_has_diagnosis_prefix"]) for c in codes_full)
if has_diag:
if not any(req in current_doc_types for req in rule.get("require_one_of", [])):
add("VETO-29", rule.get("severity", "MEDIUM"), "Global", rule.get("message"), citation=rule.get("atih_ref"))
# VETO-30 : Cohérence Urgences
urg_cfg = parcours_cfg.get("pathway_rules", {}).get("emergency_admission", {})
is_urgences = "URGENCES" in (dossier.sejour.mode_entree or "").upper()
if is_urgences and urg_cfg.get("check_urgences_match"):
# On vérifie si le DP est mentionné dans un document typé 'urgences' (si disponible)
if dossier.diagnostic_principal and dossier.diagnostic_principal.source != "urgences" and dossier.document_type != "urgences":
# Alerte légère car le DP peut évoluer, mais le TIM doit vérifier
add("VETO-30", urg_cfg.get("severity", "LOW"), "diagnostic_principal",
"Patient admis via les Urgences : vérifier que le DP correspond bien au motif d'admission initial.",
citation=urg_cfg.get("atih_ref"))
# VETO-31 : Cohérence Gravité / Mode de sortie
grav_rules = parcours_cfg.get("pathway_rules", {}).get("gravity_coherence", [])
mode_sortie = (dossier.sejour.mode_sortie or "").upper()
max_sev = 1
if dossier.ghm_estimation: max_sev = dossier.ghm_estimation.severite
for rule in grav_rules:
if any(m.upper() in mode_sortie for m in rule.get("if_mode_sortie", [])):
if max_sev < rule.get("require_min_severity", 2):
add("VETO-31", rule.get("severity", "MEDIUM"), "sejour", rule.get("message"), citation=rule.get("atih_ref"))
# -------------------------------------------------
# Post-traitement : si un veto HARD existe pour un même 'where',
@@ -481,9 +661,9 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if it.severity == "HARD":
score -= 30
elif it.severity == "MEDIUM":
score -= 10
score -= 15
else:
score -= 3
score -= 5
score = max(0, min(100, score))
return VetoReport(verdict=verdict, score_contestabilite=score, issues=issues)

View File

@@ -385,9 +385,9 @@ def decision_badge(decision) -> Markup:
labels = {
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e"),
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626"),
"RULED_OUT": ("Écarté", "#f1f5f9", "#64748b"),
"NEED_INFO": ("Info requise", "#fff7ed", "#c2410c"),
"PROMOTE_DP": ("Promu DP", "#dbeafe", "#1d4ed8"),
"RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b"),
"NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c"),
"PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8"),
}
label, bg, fg = labels.get(action, (action, "#f1f5f9", "#64748b"))
return Markup(f'<span class="badge" style="background:{bg};color:{fg};font-size:0.7rem;">{label}</span>')
@@ -428,6 +428,30 @@ def format_cpam_text(text: str | None) -> Markup:
# App factory
# ---------------------------------------------------------------------------
def human_where(value: str | None) -> str:
"""Rend une localisation technique lisible (ex: diagnostics_associes[0] -> DAS n°1)."""
if not value:
return "Global"
if value == "diagnostic_principal":
return "Diagnostic Principal"
if value == "diagnostics_associes":
return "Diagnostics Associés"
if value == "sejour":
return "Séjour"
# Matching diagnostics_associes[i]
m = re.match(r"diagnostics_associes\[(\d+)\]", value)
if m:
return f"DAS n°{int(m.group(1)) + 1}"
# Matching actes_ccam[i]
m = re.match(r"actes_ccam\[(\d+)\]", value)
if m:
return f"Acte n°{int(m.group(1)) + 1}"
return value
def create_app() -> Flask:
app = Flask(__name__)
@@ -440,6 +464,7 @@ def create_app() -> Flask:
app.jinja_env.filters["format_doc_name"] = format_doc_name
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
app.jinja_env.filters["decision_badge"] = decision_badge
app.jinja_env.filters["human_where"] = human_where
ccam_dict = load_ccam_dict()

View File

@@ -49,6 +49,64 @@
{% endif %}
</div>
{# ---- Synthèse Expert (Refonte) ---- #}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-top: 1rem; margin-bottom: 1.5rem;">
{# 1. Sécurité & Conformité (Vetos) #}
<div class="card" style="margin:0; border-top: 4px solid #ef4444; background: #fff1f2;">
<h3 style="color:#991b1b; font-size: 1rem; margin-bottom: 0.75rem;">🛡️ Sécurité & Conformité</h3>
<div style="font-size: 0.85rem; color: #7f1d1d;">
{% if dossier.veto_report and dossier.veto_report.issues %}
{% for issue in dossier.veto_report.issues if issue.severity in ['HARD', 'MEDIUM'] %}
<div style="margin-bottom: 0.5rem; border-bottom: 1px solid #fecaca; padding-bottom: 0.25rem;">
<strong>[{{ issue.severity|replace('HARD', 'Bloquant')|replace('MEDIUM', 'À vérifier') }}]</strong> {{ issue.message }}
{% if issue.citation %}<br><em style="font-size:0.75rem; opacity:0.8;">ATIH: {{ issue.citation }}</em>{% endif %}
</div>
{% endfor %}
{% else %}
<div style="color: #059669; font-weight: 600;">✅ Aucune anomalie majeure détectée.</div>
{% endif %}
</div>
</div>
{# 2. Optimisation de la Recette (CMA) #}
<div class="card" style="margin:0; border-top: 4px solid #10b981; background: #ecfdf5;">
<h3 style="color:#065f46; font-size: 1rem; margin-bottom: 0.75rem;">💰 Valorisation (CMA)</h3>
<div style="font-size: 0.85rem; color: #064e3b;">
{% set cma_alerts = [] %}
{% for alerte in dossier.alertes_codage if alerte.startswith('CMA') %}{% set _ = cma_alerts.append(alerte) %}{% endfor %}
{% if cma_alerts %}
<ul style="margin:0; padding-left: 1.2rem;">
{% for alerte in cma_alerts %}
<li style="margin-bottom: 0.25rem;">{{ alerte }}</li>
{% endfor %}
</ul>
{% else %}
<div style="opacity: 0.7;">Aucune comorbidité (CMA) détectée.</div>
{% endif %}
</div>
</div>
{# 3. Audit & Analyse IA (QC) #}
<div class="card" style="margin:0; border-top: 4px solid #3b82f6; background: #eff6ff;">
<h3 style="color:#1e40af; font-size: 1rem; margin-bottom: 0.75rem;">🔍 Audit de l'Expert IA</h3>
<div style="font-size: 0.85rem; color: #1e3a8a;">
{% set qc_alerts = [] %}
{% for alerte in dossier.alertes_codage if alerte.startswith('QC:') %}{% set _ = qc_alerts.append(alerte) %}{% endfor %}
{% if qc_alerts %}
{% for alerte in qc_alerts %}
<div style="margin-bottom: 0.5rem; border-bottom: 1px solid #bfdbfe; padding-bottom: 0.25rem; font-style: italic;">
{{ alerte|replace('QC: ', '') }}
</div>
{% endfor %}
{% else %}
<div style="opacity: 0.7;">Aucune recommandation particulière.</div>
{% endif %}
</div>
</div>
</div>
{# ---- Séjour ---- #}
{% set s = dossier.sejour %}
{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %}
@@ -301,22 +359,6 @@
</div>
{% endif %}
{# ---- Alertes de codage ---- #}
{% if dossier.alertes_codage %}
<div class="card section" style="border-left:4px solid #f97316;background:#fff7ed;">
<h3 style="color:#c2410c;">Alertes de codage ({{ dossier.alertes_codage|length }})</h3>
<ul style="margin:0;padding-left:1.2rem;">
{% for alerte in dossier.alertes_codage %}
{% if alerte.startswith('NON-CUMUL') %}
<li class="alerte-noncumul" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
{% else %}
<li class="alerte-standard" style="font-size:0.85rem;margin-bottom:0.25rem;">{{ alerte }}</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
{# ---- Contestabilité (VetoReport) ---- #}
{% if dossier.veto_report %}
{% set vr = dossier.veto_report %}
@@ -328,36 +370,37 @@
{% set vr_color = '#ef4444' %}
{% endif %}
<div class="card section" style="border-left:4px solid {{ vr_color }};">
<h3>Contestabilité du dossier</h3>
<h3>Contestabilité du dossier (Qualité PMSI)</h3>
<div style="display:flex;align-items:center;gap:1rem;">
{% if vr.verdict == 'PASS' %}
<span class="badge" style="background:#d1fae5;color:#065f46;font-weight:700;">PASS</span>
<span class="badge" style="background:#d1fae5;color:#065f46;font-weight:700;">CONFORME</span>
{% elif vr.verdict == 'NEED_INFO' %}
<span class="badge" style="background:#fef3c7;color:#92400e;font-weight:700;">NEED_INFO</span>
<span class="badge" style="background:#fef3c7;color:#92400e;font-weight:700;">À COMPLÉTER</span>
{% else %}
<span class="badge" style="background:#fee2e2;color:#dc2626;font-weight:700;">FAIL</span>
<span class="badge" style="background:#fee2e2;color:#dc2626;font-weight:700;">NON CONFORME</span>
{% endif %}
<div style="flex:1;height:8px;background:#e2e8f0;border-radius:4px;">
<div style="width:{{ vr.score_contestabilite }}%;height:100%;background:{{ vr_color }};border-radius:4px;"></div>
</div>
<span style="font-weight:600;">{{ vr.score_contestabilite }}/100</span>
<span style="font-weight:600;">Score : {{ vr.score_contestabilite }}/100</span>
</div>
{% if vr.issues %}
<details style="margin-top:0.5rem;">
<summary style="font-size:0.8rem;color:#64748b;">Problèmes détectés ({{ vr.issues|length }})</summary>
<summary style="font-size:0.8rem;color:#64748b;cursor:pointer;">Détail des anomalies détectées ({{ vr.issues|length }})</summary>
<table style="margin-top:0.25rem;">
<thead><tr><th>Veto</th><th>Sévérité</th><th>Localisation</th><th>Message</th></tr></thead>
<thead><tr><th>Code Règle</th><th>Sévérité</th><th>Localisation</th><th>Message d'alerte</th><th>Référence ATIH</th></tr></thead>
<tbody>
{% for issue in vr.issues %}
<tr>
<td><code style="font-size:0.75rem;">{{ issue.veto }}</code></td>
<td>
{% if issue.severity == 'HARD' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">HARD</span>
{% elif issue.severity == 'MEDIUM' %}<span class="badge" style="background:#fef3c7;color:#92400e;">MEDIUM</span>
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">LOW</span>{% endif %}
{% if issue.severity == 'HARD' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Bloquant</span>
{% elif issue.severity == 'MEDIUM' %}<span class="badge" style="background:#fef3c7;color:#92400e;">À vérifier</span>
{% else %}<span class="badge" style="background:#f0fdf4;color:#166534;">Optimisation</span>{% endif %}
</td>
<td style="font-size:0.75rem;color:#64748b;">{{ issue.where }}</td>
<td style="font-size:0.75rem;color:#64748b;">{{ issue.where|human_where }}</td>
<td style="font-size:0.8rem;">{{ issue.message }}</td>
<td style="font-size:0.75rem;color:#475569;font-style:italic;">{{ issue.citation or '—' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -506,7 +549,7 @@
<details style="margin-top:0.3rem;"><summary style="font-size:0.7rem;color:#0369a1;cursor:pointer;">preuves ({{ das.preuves_cliniques|length }})</summary>
<ul style="margin:0.15rem 0 0 0;padding-left:1rem;font-size:0.75rem;">
{% for p in das.preuves_cliniques %}
<li><span style="font-weight:600;color:#0369a1;">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">&rarr; {{ p.interpretation }}</span></li>
<li><span style="font-weight:600;{% if p.type == 'biologie' %}color:#0891b2;{% else %}color:#0369a1;{% endif %}">[{{ p.type }}]</span> {{ p.element }} <span style="color:#64748b;">&rarr; {{ p.interpretation }}</span></li>
{% endfor %}
</ul>
</details>

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: