feat: fix extraction DP Trackare + 5 règles ATIH (veto engine)

- Fix DP : les diagnostics Trackare marqués "principal" ne sont plus
  filtrés par is_valid_diagnostic_text() (3 dossiers récupérés)
- VETO-20 : Z code interdit en DP (sauf whitelist Z09/Z51/Z54/Z75...)
- VETO-21 : Code R (symptôme) en DP → alerte CMD 23
- VETO-22 : Même catégorie 3 chars en DP+DAS (redondance)
- VETO-23 : Exclusions mutuelles (E10↔E11, I10↔I11-I13)
- VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
- 24 tests unitaires, 699 tests passent sans régression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 00:39:07 +01:00
parent 909e051cc9
commit 0b94299975
4 changed files with 345 additions and 2 deletions

View File

@@ -315,14 +315,17 @@ def _extract_diagnostics(
# Diagnostics codés depuis Trackare (prioritaires)
for diag in parsed.get("diagnostics", []):
texte = clean_diagnostic_text(diag.get("libelle", ""))
if not is_valid_diagnostic_text(texte):
is_principal = diag.get("type", "").lower() == "principal"
# Le DP Trackare est toujours accepté (pré-codé avec CIM-10 validé).
# Seuls les DAS passent le filtre anti-bruit.
if not is_principal and not is_valid_diagnostic_text(texte):
continue
d = Diagnostic(
texte=texte,
cim10_suggestion=diag.get("code_cim10"),
source="trackare",
)
if diag.get("type", "").lower() == "principal":
if is_principal:
dossier.diagnostic_principal = d
else:
dossier.diagnostics_associes.append(d)

View File

@@ -372,6 +372,84 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
if dp and dp.cim10_suggestion and _overconf(dp):
add("VETO-12", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} en high sans preuve")
# -------------------------------------------------
# VETO-20 : Z code interdit en DP (sauf whitelist ATIH)
# Règle PMSI : les codes Z ne sont autorisés en DP que pour un
# nombre limité de motifs (chimiothérapie, suivi post-traitement, etc.)
# -------------------------------------------------
_Z_DP_WHITELIST = {"Z09", "Z51", "Z54", "Z75", "Z03", "Z04", "Z38", "Z50", "Z08"}
if dp and dp.cim10_suggestion and str(dp.cim10_suggestion).startswith("Z"):
z3 = str(dp.cim10_suggestion)[:3]
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.).")
# -------------------------------------------------
# VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible
# Règle PMSI : un symptôme en DP indique un bilan incomplet.
# -------------------------------------------------
if dp and dp.cim10_suggestion and str(dp.cim10_suggestion).startswith("R"):
# Vérifier si un diagnostic précis existe dans les DAS
has_precise = any(
das.cim10_suggestion and not str(das.cim10_suggestion).startswith(("R", "Z"))
and not _is_ruled_out(das)
for das in dossier.diagnostics_associes
)
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.")
# -------------------------------------------------
# VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS
# Règle PMSI : redondance de codage suspecte.
# -------------------------------------------------
if dp and dp.cim10_suggestion and len(str(dp.cim10_suggestion)) >= 3:
dp_cat = str(dp.cim10_suggestion)[:3]
for i, das in enumerate(dossier.diagnostics_associes):
if _is_ruled_out(das):
continue
if das.cim10_suggestion and len(str(das.cim10_suggestion)) >= 3:
das_cat = str(das.cim10_suggestion)[:3]
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.")
# -------------------------------------------------
# VETO-23 : Exclusions mutuelles (diabète type 1 vs type 2, HTA)
# Règle PMSI : codes incompatibles dans le même séjour.
# -------------------------------------------------
all_codes = set()
if dp and dp.cim10_suggestion:
all_codes.add(str(dp.cim10_suggestion)[:3])
for das in dossier.diagnostics_associes:
if not _is_ruled_out(das) and das.cim10_suggestion:
all_codes.add(str(das.cim10_suggestion)[:3])
_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)
# -------------------------------------------------
# VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
# Règle PMSI : les codes de lésion doivent être associés
# à un code de cause externe.
# -------------------------------------------------
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
)
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in all_codes)
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.")
# -------------------------------------------------
# Post-traitement : si un veto HARD existe pour un même 'where',