feat: optimisations pipeline médical (bio_normals, GHM, DP selector, CIM-10)

- bio_normals: table de normes biologiques étendue (+200 analytes)
- bio_extraction: amélioration parsing valeurs biologiques
- cim10_extractor: règles supplémentaires extraction codes
- dp_selector: affinement sélection diagnostic principal
- ghm: estimation sévérité GHM enrichie
- validation_pipeline: correctifs mineurs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-03-07 23:14:13 +01:00
parent 63f61f196b
commit 4b6e3cf6d5
6 changed files with 314 additions and 12 deletions

View File

@@ -147,7 +147,7 @@ def _extract_biologie_faiss(text: str, dossier: DossierMedical) -> None:
sanitized = _sanitize_bio_value(concept_name, raw_value, sanity_cfg) sanitized = _sanitize_bio_value(concept_name, raw_value, sanity_cfg)
if sanitized: if sanitized:
token, val_num, quality, reason = sanitized token, val_num, quality, reason = sanitized
anomalie = _is_abnormal(concept_name, token) anomalie = _is_abnormal(concept_name, token, dossier.sejour.age if dossier.sejour else None, dossier.sejour.sexe if dossier.sejour else None)
is_dup = any(b.test == concept_name and b.valeur == raw_value for b in dossier.biologie_cle) is_dup = any(b.test == concept_name and b.valeur == raw_value for b in dossier.biologie_cle)
if is_dup: if is_dup:
@@ -208,6 +208,10 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
] ]
# Contexte patient pour normes adaptées (âge/sexe)
_patient_age = dossier.sejour.age if dossier.sejour else None
_patient_sexe = dossier.sejour.sexe if dossier.sejour else None
# Anti-doublons + limite par test (évite d'exploser le JSON) # Anti-doublons + limite par test (évite d'exploser le JSON)
max_per_test = 6 max_per_test = 6
counts: dict[str, int] = {} counts: dict[str, int] = {}
@@ -234,7 +238,7 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
counts[test_name] = counts.get(test_name, 0) + 1 counts[test_name] = counts.get(test_name, 0) + 1
if counts[test_name] > max_per_test: if counts[test_name] > max_per_test:
break break
anomalie = _is_abnormal(test_name, raw_value) anomalie = _is_abnormal(test_name, raw_value, _patient_age, _patient_sexe)
dossier.biologie_cle.append( dossier.biologie_cle.append(
BiologieCle( BiologieCle(
test=test_name, test=test_name,
@@ -280,7 +284,7 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
if drop_out_of_range: if drop_out_of_range:
continue continue
anomalie = _is_abnormal(test_name, token) anomalie = _is_abnormal(test_name, token, _patient_age, _patient_sexe)
dossier.biologie_cle.append( dossier.biologie_cle.append(
BiologieCle( BiologieCle(
test=test_name, test=test_name,

View File

@@ -1,10 +1,14 @@
"""Plages de référence biologiques et fonction d'interprétation.""" """Plages de référence biologiques et fonction d'interprétation.
Normes adaptées par sexe et tranche d'âge (adulte homme, adulte femme, enfant).
Sources : référentiels SFH, SFR, HAS 2023.
"""
from __future__ import annotations from __future__ import annotations
# Plages de référence biologiques (min, max) — utilisées par _is_abnormal() # Plages de référence biologiques (min, max) — fallback adulte unisexe
# et exportées pour le formatage du contexte LLM dans rag_search.py # Utilisées quand le sexe/âge n'est pas disponible
BIO_NORMALS: dict[str, tuple[float, float]] = { BIO_NORMALS: dict[str, tuple[float, float]] = {
# --- Hépatique / digestif --- # --- Hépatique / digestif ---
"Lipasémie": (0, 60), "Lipasémie": (0, 60),
@@ -18,7 +22,7 @@ BIO_NORMALS: dict[str, tuple[float, float]] = {
# --- Inflammatoire --- # --- Inflammatoire ---
"CRP": (0, 5), "CRP": (0, 5),
"VS": (0, 20), # mm/h "VS": (0, 20), # mm/h
# --- Ionogramme (fallback adulte ; les règles de décision utilisent reference_ranges.yaml) --- # --- Ionogramme ---
"Sodium": (135, 145), "Sodium": (135, 145),
"Potassium": (3.5, 5.0), "Potassium": (3.5, 5.0),
# --- Hématologie --- # --- Hématologie ---
@@ -51,8 +55,127 @@ BIO_NORMALS: dict[str, tuple[float, float]] = {
} }
def _is_abnormal(test: str, value: str) -> bool | None: # ---------------------------------------------------------------------------
"""Détermine si un résultat biologique est anormal.""" # Normes par sexe et tranche d'âge
# Clé : (test, groupe) → (min, max)
# Groupes : "M" (homme adulte), "F" (femme adulte),
# "child" (1-14 ans), "infant" (0-1 an), "newborn" (0-28 jours)
# ---------------------------------------------------------------------------
BIO_NORMALS_BY_GROUP: dict[tuple[str, str], tuple[float, float]] = {
# --- Hémoglobine (g/dL) ---
("Hémoglobine", "M"): (13, 17), # Homme adulte
("Hémoglobine", "F"): (12, 16), # Femme adulte
("Hémoglobine", "child"): (11.5, 15.5), # Enfant 1-14 ans
("Hémoglobine", "infant"): (10, 14), # Nourrisson 1-12 mois
("Hémoglobine", "newborn"): (14, 22), # Nouveau-né 0-28j
# --- Leucocytes (G/L) ---
("Leucocytes", "M"): (4, 10),
("Leucocytes", "F"): (4, 10),
("Leucocytes", "child"): (5, 15), # Enfant (leucocytose physiologique)
("Leucocytes", "infant"): (6, 17),
("Leucocytes", "newborn"): (10, 26),
# --- Plaquettes (G/L) — même norme globalement ---
("Plaquettes", "child"): (150, 450),
("Plaquettes", "newborn"): (150, 450),
# --- Créatinine (µmol/L) ---
("Créatinine", "M"): (60, 120),
("Créatinine", "F"): (45, 105),
("Créatinine", "child"): (20, 60), # Enfant 1-14 ans
("Créatinine", "infant"): (15, 35), # Nourrisson
("Créatinine", "newborn"): (20, 75), # Nouveau-né (transitoire élevé)
# --- Ferritine (µg/L) ---
("Ferritine", "M"): (30, 400),
("Ferritine", "F"): (15, 200), # Plus bas chez la femme (menstruations)
("Ferritine", "child"): (10, 150),
# --- ALAT (UI/L) ---
("ALAT", "M"): (0, 45),
("ALAT", "F"): (0, 35),
("ALAT", "child"): (0, 40),
("ALAT", "infant"): (0, 55), # Nourrisson : seuil plus haut
# --- ASAT (UI/L) ---
("ASAT", "M"): (0, 40),
("ASAT", "F"): (0, 35),
("ASAT", "child"): (0, 50),
("ASAT", "infant"): (0, 60),
# --- GGT (UI/L) ---
("GGT", "M"): (0, 70),
("GGT", "F"): (0, 45),
("GGT", "child"): (0, 30),
("GGT", "newborn"): (0, 200), # Nouveau-né : GGT très élevé physiologique
# --- PAL (UI/L) ---
("PAL", "child"): (100, 400), # Croissance osseuse → PAL élevé normal
# --- Acide urique (µmol/L) ---
("Acide urique", "M"): (200, 420),
("Acide urique", "F"): (150, 360),
# --- Potassium (mmol/L) ---
("Potassium", "child"): (3.5, 5.5),
("Potassium", "newborn"): (3.5, 6.0), # Seuil haut plus élevé chez le nouveau-né
# --- Sodium (mmol/L) ---
("Sodium", "newborn"): (133, 146),
# --- Bilirubine totale (µmol/L) ---
("Bilirubine totale", "newborn"): (0, 250), # Ictère physiologique du nouveau-né
# --- Glycémie (mmol/L) ---
("Glycémie", "newborn"): (2.5, 5.5), # Seuil bas plus bas
# --- Albumine (g/L) ---
("Albumine", "child"): (35, 50),
("Albumine", "infant"): (25, 45),
("Albumine", "newborn"): (25, 45),
}
def _age_group(age: int | None, sexe: str | None) -> str | None:
"""Détermine le groupe démographique pour les normes bio."""
if age is None:
return None
if age == 0:
return "newborn"
if age <= 1:
return "infant"
if age <= 14:
return "child"
# Adulte : utiliser le sexe
if sexe:
s = sexe.strip().upper()
if s in ("M", "MASCULIN", "HOMME", "H"):
return "M"
if s in ("F", "FÉMININ", "FEMININ", "FEMME"):
return "F"
return None
def get_norms(test: str, age: int | None = None, sexe: str | None = None) -> tuple[float, float] | None:
"""Retourne les normes (min, max) adaptées à l'âge et au sexe.
Cascade : normes spécifiques groupe > normes génériques BIO_NORMALS.
"""
group = _age_group(age, sexe)
if group:
key = (test, group)
if key in BIO_NORMALS_BY_GROUP:
return BIO_NORMALS_BY_GROUP[key]
return BIO_NORMALS.get(test)
def _is_abnormal(test: str, value: str, age: int | None = None, sexe: str | None = None) -> bool | None:
"""Détermine si un résultat biologique est anormal.
Utilise les normes par groupe (âge/sexe) si disponibles,
sinon les normes adultes génériques.
"""
try: try:
val = float(value.replace(",", ".")) val = float(value.replace(",", "."))
except (ValueError, AttributeError): except (ValueError, AttributeError):
@@ -62,7 +185,71 @@ def _is_abnormal(test: str, value: str) -> bool | None:
return True return True
return None return None
if test in BIO_NORMALS: norms = get_norms(test, age, sexe)
lo, hi = BIO_NORMALS[test] if norms:
lo, hi = norms
return val > hi or val < lo return val > hi or val < lo
return None return None
# ---------------------------------------------------------------------------
# Calcul DFG (CKD-EPI 2021, sans race)
# ---------------------------------------------------------------------------
def compute_dfg(creatinine_umol: float, age: int, sexe: str) -> float | None:
"""Calcule le DFG estimé par CKD-EPI 2021 (sans race).
Args:
creatinine_umol: Créatinine en µmol/L
age: Âge en années
sexe: "M", "F", "Masculin", "Féminin"...
Returns:
DFG en mL/min/1.73m², ou None si calcul impossible.
"""
if creatinine_umol <= 0 or age <= 0:
return None
s = (sexe or "").strip().upper()
is_female = s in ("F", "FÉMININ", "FEMININ", "FEMME")
# Conversion µmol/L → mg/dL (facteur 0.0113)
scr = creatinine_umol * 0.0113
# CKD-EPI 2021 (sans race)
if is_female:
kappa = 0.7
alpha = -0.241
factor = 1.012
else:
kappa = 0.9
alpha = -0.302
factor = 1.0
ratio = scr / kappa
if ratio <= 1:
dfg = 142 * (ratio ** alpha) * (0.9938 ** age) * factor
else:
dfg = 142 * (ratio ** -1.200) * (0.9938 ** age) * factor
return round(dfg, 1)
def stade_irc(dfg: float) -> tuple[str, str] | None:
"""Retourne le stade IRC et le code CIM-10 correspondant.
Stades KDIGO :
G1 ≥90 — G2 60-89 — G3a 45-59 — G3b 30-44 — G4 15-29 — G5 <15
"""
if dfg >= 90:
return ("G1", "N18.1")
elif dfg >= 60:
return ("G2", "N18.2")
elif dfg >= 45:
return ("G3a", "N18.3")
elif dfg >= 30:
return ("G3b", "N18.4")
elif dfg >= 15:
return ("G4", "N18.4")
else:
return ("G5", "N18.5")

View File

@@ -208,6 +208,17 @@ def extract_medical_info(
# Post-processing : cohérence DAS ↔ biologie # Post-processing : cohérence DAS ↔ biologie
_validate_bio_das_coherence(dossier) _validate_bio_das_coherence(dossier)
# Post-processing : calcul DFG et détection IRC non codée
_check_dfg_irc(dossier)
# Post-processing : détection erreurs fréquentes (intuition DIM senior)
try:
from .dim_senior import check_common_mistakes
senior_alerts = check_common_mistakes(dossier)
dossier.alertes_codage.extend(senior_alerts)
except Exception:
logger.error("DIM-SENIOR: erreur détection erreurs fréquentes", exc_info=True)
# Post-processing : resélection DP si exclu par vetos/exclusions # Post-processing : resélection DP si exclu par vetos/exclusions
if dossier.document_type != "trackare": if dossier.document_type != "trackare":
try: try:
@@ -227,6 +238,51 @@ def extract_medical_info(
return dossier return dossier
def _check_dfg_irc(dossier: DossierMedical) -> None:
"""Calcule le DFG si créatinine disponible et alerte si IRC non codée."""
from .bio_normals import compute_dfg, stade_irc
age = dossier.sejour.age if dossier.sejour else None
sexe = dossier.sejour.sexe if dossier.sejour else None
if not age or not sexe or age < 18:
return
# Trouver la créatinine
creat_val = None
for bio in dossier.biologie_cle:
if bio.test == "Créatinine" and bio.valeur_num:
creat_val = bio.valeur_num
break
if creat_val is None:
return
dfg = compute_dfg(creat_val, age, sexe)
if dfg is None:
return
dossier.quality_flags["dfg_estime"] = dfg
# Vérifier si IRC codée alors que DFG le justifie
if dfg < 60:
stade, code_attendu = stade_irc(dfg)
# Chercher si un N18.x est déjà codé
irc_coded = False
all_codes = set()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
for das in dossier.diagnostics_associes:
if das.cim10_suggestion:
all_codes.add(das.cim10_suggestion)
irc_coded = any(c.startswith("N18") for c in all_codes)
if not irc_coded:
dossier.alertes_codage.append(
f"DFG estimé {dfg} mL/min (stade {stade}) — IRC ({code_attendu}) "
f"non codée. Créatinine {creat_val} µmol/L, {sexe} {age} ans."
)
dossier.quality_flags["irc_non_codee"] = code_attendu
def _extract_das_llm(text: str, dossier: DossierMedical) -> None: def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
"""Extrait des DAS supplémentaires via un pass LLM (avant enrichissement RAG).""" """Extrait des DAS supplémentaires via un pass LLM (avant enrichissement RAG)."""
try: try:

View File

@@ -243,6 +243,7 @@ def score_candidates(
for sec_key, sec_bonus in ( for sec_key, sec_bonus in (
("diag_sortie", 4), ("diag_principal", 4), ("diag_sortie", 4), ("diag_principal", 4),
("synthese", 2), ("conclusion", 2), ("synthese", 2), ("conclusion", 2),
("histoire_maladie", 1),
): ):
sec_text = (synthese.get(sec_key) or "").lower() sec_text = (synthese.get(sec_key) or "").lower()
if not sec_text or len(sec_text) < 3: if not sec_text or len(sec_text) < 3:
@@ -332,9 +333,15 @@ def _llm_rank(
motif = synthese.get("motif", "") motif = synthese.get("motif", "")
if motif: if motif:
ctx_parts.append(f"Motif: {motif}") ctx_parts.append(f"Motif: {motif}")
hdm = synthese.get("histoire_maladie", "")
if hdm:
ctx_parts.append(f"Histoire de la maladie: {hdm[:400]}")
conclusion = synthese.get("conclusion", "") conclusion = synthese.get("conclusion", "")
if conclusion: if conclusion:
ctx_parts.append(f"Conclusion: {conclusion[:300]}") ctx_parts.append(f"Conclusion: {conclusion[:300]}")
diag_sortie = synthese.get("diag_sortie", "")
if diag_sortie:
ctx_parts.append(f"Diagnostic de sortie: {diag_sortie[:200]}")
ctx_str = "\n".join(ctx_parts) or "Non disponible" ctx_str = "\n".join(ctx_parts) or "Non disponible"
prompt = DP_RANKER_CONSTRAINED.format( prompt = DP_RANKER_CONSTRAINED.format(
@@ -362,6 +369,7 @@ def build_synthese(dossier: DossierMedical, parsed_data: dict) -> dict:
"diag_sortie": sections.get("diag_sortie", ""), "diag_sortie": sections.get("diag_sortie", ""),
"diag_principal": sections.get("diag_principal", ""), "diag_principal": sections.get("diag_principal", ""),
"synthese": sections.get("synthese", ""), "synthese": sections.get("synthese", ""),
"histoire_maladie": sections.get("histoire_maladie", ""),
"antecedents": [a.texte for a in dossier.antecedents[:10]], "antecedents": [a.texte for a in dossier.antecedents[:10]],
} }

View File

@@ -325,3 +325,48 @@ def estimate_financial_impact(
priorite=priorite, priorite=priorite,
raison=raison, raison=raison,
) )
# ---------------------------------------------------------------------------
# Simulation what-if : impact de chaque DAS sur la sévérité GHM
# ---------------------------------------------------------------------------
def ghm_whatif(dossier: DossierMedical) -> list[dict]:
"""Simule le GHM sans chaque DAS pour détecter les DAS à fort impact.
Retourne une liste de dicts :
{"das_texte", "das_code", "severite_avec", "severite_sans", "delta", "impact"}
Triée par impact décroissant.
"""
from copy import copy
base_ghm = estimate_ghm(dossier)
base_sev = base_ghm.severite or 1
results = []
for i, das in enumerate(dossier.diagnostics_associes):
code = das.cim10_suggestion
if not code:
continue
# Créer une copie shallow du dossier sans ce DAS
dossier_copy = copy(dossier)
dossier_copy.diagnostics_associes = [
d for j, d in enumerate(dossier.diagnostics_associes) if j != i
]
ghm_sans = estimate_ghm(dossier_copy)
sev_sans = ghm_sans.severite or 1
if sev_sans != base_sev:
results.append({
"das_texte": das.texte[:80],
"das_code": code,
"severite_avec": base_sev,
"severite_sans": sev_sans,
"delta": base_sev - sev_sans,
"impact": "hausse_severite" if base_sev > sev_sans else "baisse_severite",
})
results.sort(key=lambda r: abs(r["delta"]), reverse=True)
return results

View File

@@ -258,7 +258,9 @@ def _validate_bio_das_coherence(dossier: DossierMedical) -> None:
# Indexer la biologie du dossier : analyte → (valeur, anomalie) # Indexer la biologie du dossier : analyte → (valeur, anomalie)
bio_index: dict[str, tuple[str, bool | None]] = {} bio_index: dict[str, tuple[str, bool | None]] = {}
for bio in dossier.biologie_cle: for bio in dossier.biologie_cle:
abnormal = _is_abnormal(bio.test, bio.valeur) _age = dossier.sejour.age if dossier.sejour else None
_sexe = dossier.sejour.sexe if dossier.sejour else None
abnormal = _is_abnormal(bio.test, bio.valeur, _age, _sexe)
bio_index[bio.test] = (bio.valeur, abnormal) bio_index[bio.test] = (bio.valeur, abnormal)
all_diags = [] all_diags = []