feat: dictionnaire CIM-10 complet (10 893 codes) + robustesse regex

- Nouveau module cim10_dict.py : extraction depuis metadata.json FAISS,
  lookup intelligent avec normalisation Unicode (accents, trémas, apostrophes)
- cim10_extractor : _lookup_cim10 utilise le dictionnaire complet,
  _find_dp normalisé, _find_das élargi à 20 patterns (cardio, métabo,
  infectieux, rénal...), biologie +6 tests (TGO/TGP, Hb, créatinine),
  traitements sans limite de lignes
- document_classifier : scoring pondéré, classify_with_confidence(), scan 5000 chars
- CLI --build-dict pour regénérer data/cim10_dict.json
- 32 nouveaux tests unitaires (124 total, 0 échec)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-11 08:09:32 +01:00
parent 037d255aa0
commit 12f4479cd2
6 changed files with 608 additions and 91 deletions

View File

@@ -9,6 +9,7 @@ from typing import Optional
logger = logging.getLogger(__name__)
from .cim10_dict import lookup as dict_lookup, normalize_text
from ..config import (
ActeCCAM,
BiologieCle,
@@ -253,33 +254,77 @@ def _extract_diagnostics(
def _find_diagnostic_principal(text_lower: str, conclusion: str) -> Diagnostic | None:
"""Trouve le diagnostic principal dans le texte."""
conclusion_lower = conclusion.lower()
"""Trouve le diagnostic principal dans le texte.
# Chercher dans la conclusion d'abord
Normalise le texte avant matching pour gérer les variations d'accents/casse.
"""
conclusion_norm = normalize_text(conclusion)
# Chercher dans la conclusion d'abord via CIM10_MAP (domain override)
for terme, code in CIM10_MAP.items():
if terme in conclusion_lower:
if normalize_text(terme) in conclusion_norm:
return Diagnostic(texte=terme.capitalize(), cim10_suggestion=code)
# Patterns courants pour le DP
text_norm = normalize_text(text_lower)
# Patterns courants pour le DP (normalisés, sans accents)
dp_patterns = [
r"pancréatite\s+aigu[eë]\s+(?:d'origine\s+)?lithiasique",
r"pancréatite\s+aigu[eë]\s+biliaire",
r"pancréatite\s+aigu[eë]",
r"pancreatite\s+aigue\s+(?:d'origine\s+)?lithiasique",
r"pancreatite\s+aigue\s+biliaire",
r"pancreatite\s+aigue",
]
for pat in dp_patterns:
if re.search(pat, text_lower):
matched = re.search(pat, text_lower).group(0)
m = re.search(pat, text_norm)
if m:
matched = m.group(0)
code = _lookup_cim10(matched)
return Diagnostic(texte=matched.capitalize(), cim10_suggestion=code)
return None
# Patterns DAS : (pattern_normalisé, label, code_fallback)
# Les patterns sont appliqués sur du texte normalisé (sans accents, lowercase)
_DAS_PATTERNS: list[tuple[str, str, str]] = [
# Lithiases biliaires
(r"lithiase\s+(?:du\s+)?(?:bas\s+)?choledoque", "Lithiase du cholédoque", "K80.5"),
(r"vesicule\s+lithiasique|lithiases?\s+vesiculaire", "Lithiase vésiculaire", "K80.2"),
# Inflammation biliaire
(r"cholecystite\s+aigue", "Cholécystite aiguë", "K81.0"),
(r"angiocholite|cholangite", "Angiocholite", "K83.0"),
# Réactions médicamenteuses
(r"eruption\s+cutanee|toxidermie|reaction\s+au\s+tramadol", "Éruption cutanée médicamenteuse", "L27.0"),
# Cardiovasculaire
(r"hypertension\s+arterielle|\bhta\b", "Hypertension artérielle", "I10"),
(r"fibrillation\s+auriculaire|\bfa\b(?:\s+paroxystique)?|\bacfa\b", "Fibrillation auriculaire", "I48.9"),
(r"embolie\s+pulmonaire", "Embolie pulmonaire", "I26.9"),
(r"thrombose\s+veineuse\s+profonde|\btvp\b", "Thrombose veineuse profonde", "I80.2"),
# Métabolique
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+2|diabete\s+type\s*2", "Diabète de type 2", "E11.9"),
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+1|diabete\s+type\s*1", "Diabète de type 1", "E10.9"),
(r"dyslipidemie|hypercholesterolemie", "Dyslipidémie", "E78.5"),
(r"denutrition|malnutrition", "Dénutrition", "E46"),
# Infectieux
(r"pneumopathie|pneumonie", "Pneumopathie", "J18.9"),
(r"infection\s+urinaire|pyelonephrite", "Infection urinaire", "N39.0"),
(r"\bsepsis\b|septicemie|choc\s+septique", "Sepsis", "A41.9"),
# Rénal
(r"insuffisance\s+renale", "Insuffisance rénale", "N19"),
# Hématologique
(r"anemie", "Anémie", "D64.9"),
# Addictions
(r"tabagisme|tabac\s+actif", "Tabagisme", "F17.2"),
(r"ethylisme|alcoolisme|intoxication\s+ethylique", "Éthylisme", "F10.1"),
]
def _find_diagnostics_associes(
text_lower: str, conclusion: str, dossier: DossierMedical
) -> list[Diagnostic]:
"""Trouve les diagnostics associés."""
"""Trouve les diagnostics associés.
Utilise des patterns normalisés (sans accents) pour une détection robuste.
"""
das: list[Diagnostic] = []
existing_codes = set()
if dossier.diagnostic_principal:
@@ -287,32 +332,21 @@ def _find_diagnostics_associes(
for d in dossier.diagnostics_associes:
existing_codes.add(d.cim10_suggestion)
# Lithiase cholédoque
if re.search(r"lithiase\s+(?:du\s+)?(?:bas\s+)?cholédoque", text_lower):
if "K80.5" not in existing_codes:
das.append(Diagnostic(texte="Lithiase du cholédoque", cim10_suggestion="K80.5"))
existing_codes.add("K80.5")
text_norm = normalize_text(text_lower)
# Éruption médicamenteuse
if re.search(r"éruption\s+cutanée|eruption\s+cutanée|toxidermie|réaction\s+au\s+tramadol", text_lower):
if "L27.0" not in existing_codes:
das.append(Diagnostic(texte="Éruption cutanée médicamenteuse", cim10_suggestion="L27.0"))
existing_codes.add("L27.0")
# Patterns DAS
for pat, label, code in _DAS_PATTERNS:
if re.search(pat, text_norm) and code not in existing_codes:
das.append(Diagnostic(texte=label, cim10_suggestion=code))
existing_codes.add(code)
# Obésité (IMC >= 30)
if re.search(r"imc\s*[:=]?\s*(\d{2,3}[.,]\d+)", text_lower):
m = re.search(r"imc\s*[:=]?\s*(\d{2,3}[.,]\d+)", text_lower)
if m:
imc_val = float(m.group(1).replace(",", "."))
if imc_val >= 30 and "E66.0" not in existing_codes:
das.append(Diagnostic(texte=f"Obésité (IMC {imc_val})", cim10_suggestion="E66.0"))
existing_codes.add("E66.0")
# Lithiases vésiculaires
if re.search(r"vésicule\s+lithiasique|lithiases?\s+vésiculaire", text_lower):
if "K80.2" not in existing_codes:
das.append(Diagnostic(texte="Lithiase vésiculaire", cim10_suggestion="K80.2"))
existing_codes.add("K80.2")
# Obésité (IMC >= 30) — pattern spécial avec extraction de valeur
m = re.search(r"imc\s*[:=]?\s*(\d{2,3}[.,]\d+)", text_norm)
if m:
imc_val = float(m.group(1).replace(",", "."))
if imc_val >= 30 and "E66.0" not in existing_codes:
das.append(Diagnostic(texte=f"Obésité (IMC {imc_val})", cim10_suggestion="E66.0"))
existing_codes.add("E66.0")
return das
@@ -399,7 +433,7 @@ def _extract_traitements(
if not drug.negation and drug.code_atc:
drug_atc[drug.texte.lower()] = drug.code_atc
# Depuis le texte — section "TTT de sortie" (limiter à quelques lignes)
# Depuis le texte — section "TTT de sortie" (sans limite de lignes)
m = re.search(
r"(?:TTT|Traitement)\s+de\s+sortie\s*[:]?\s*\n?(.*?)(?=\n\s*(?:Devenir|Rédigé|Cordialement|Patient:|Episode|Le \d{2}/\d{2}|\n\n)|$)",
text,
@@ -408,17 +442,28 @@ def _extract_traitements(
if m:
block = m.group(1).strip()
lines = block.split("\n")
for line in lines[:10]: # Limiter à 10 lignes max
for line in lines:
line = line.strip().lstrip("- •")
if not line or len(line) <= 2:
continue
# Ignorer les footers et lignes non-médicament
if re.match(r"^(Patient|Episode|Le \d|Page|V\d)", line):
# Conditions d'arrêt : footers, signatures, metadata
if re.match(
r"^(Patient|Episode|Le \d|Page\s+\d|V\d|Rédigé|Cordialement|Dr\s|Docteur|Signature|Date|Fait\s+le)",
line,
re.IGNORECASE,
):
break
med = line
poso = None
# Séparer médicament et posologie
poso_match = re.search(r"\s+(si besoin|matin|soir|midi|\d+\s*(?:mg|cp|gel).*)", line, re.IGNORECASE)
# Séparer médicament et posologie (pattern élargi)
poso_match = re.search(
r"\s+(si besoin|matin|soir|midi|"
r"\d+\s*(?:mg|cp|gel|sachet|comprim[ée]|g[ée]lule).*|"
r"\d+\s*(?:x|fois)\s*/?\s*(?:j(?:our)?|semaine)|"
r"pendant\s+\d+\s*jours?)",
line,
re.IGNORECASE,
)
if poso_match:
med = line[:poso_match.start()].strip()
poso = poso_match.group(1).strip()
@@ -460,16 +505,24 @@ def _match_drug_atc(med_name: str, drug_atc: dict[str, str]) -> Optional[str]:
def _extract_biologie(text: str, dossier: DossierMedical) -> None:
"""Extrait les résultats biologiques clés."""
"""Extrait les résultats biologiques clés.
Supporte les aliases (TGO/TGP, Hb), variantes d'unités (UI/L, µmol/L, g/dL),
et des tests additionnels (hémoglobine, plaquettes, leucocytes, créatinine).
"""
bio_patterns = [
(r"[Ll]ipas[ée]mie\s*(?:[àa=:])?\s*(\d+)", "Lipasémie", None),
(r"CRP\s*[=:à]?\s*(\d+(?:[.,]\d+)?)", "CRP", None),
(r"ASAT\s*[=:à]?\s*([\d.,]+)\s*(?:N|U/L)?", "ASAT", None),
(r"ALAT\s*[=:à]?\s*([\d.,]+)\s*(?:N|U/L)?", "ALAT", None),
(r"GGT\s*[=:à]?\s*(\d+)\s*(?:U/L)?", "GGT", None),
(r"PAL\s*[=:à]?\s*(\d+)\s*(?:U/L)?", "PAL", None),
(r"[Bb]ilirubine\s+(?:totale\s+)?[àa=:]\s*(\d+)\s*(?:µmol/L)?", "Bilirubine totale", None),
(r"troponine\s+(gative|positive|normale)", "Troponine", None),
(r"[Ll]ipas[ée]mie\s*(?:[àa=:])?\s*(\d+)\s*(?:UI/L|U/L)?", "Lipasémie", None),
(r"CRP\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll])?", "CRP", None),
(r"(?:ASAT|TGO)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ASAT", None),
(r"(?:ALAT|TGP)\s*[=:àa]?\s*([\d.,]+)\s*(?:N|U(?:I)?/L)?", "ALAT", None),
(r"GGT\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "GGT", None),
(r"PAL\s*[=:àa]?\s*(\d+)\s*(?:U(?:I)?/L)?", "PAL", None),
(r"[Bb]ilirubine\s+(?:totale\s+)?[àa=:]\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Bilirubine totale", None),
(r"[Tt]roponine\s+(?:us\s+)?(n[ée]gative|positive|normale)", "Troponine", None),
(r"(?:[Hh][ée]moglobine|Hb)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/dL|g/L)?", "Hémoglobine", None),
(r"[Pp]laquettes?\s*[=:àa]?\s*(\d+(?:\s*000)?)\s*(?:/mm3|G/L)?", "Plaquettes", None),
(r"[Ll]eucocytes?\s*[=:àa]?\s*(\d+(?:\s*000)?)\s*(?:/mm3|G/L)?", "Leucocytes", None),
(r"[Cc]r[ée]atinine?\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:µmol/L|mg/dL)?", "Créatinine", None),
]
for pattern, test_name, _ in bio_patterns:
@@ -589,12 +642,11 @@ def _find_act_date(text: str, act_pattern: str) -> str | None:
def _lookup_cim10(text: str) -> str | None:
"""Cherche un code CIM-10 pour un texte donné."""
text_lower = text.lower().strip()
for terme, code in CIM10_MAP.items():
if terme in text_lower:
return code
return None
"""Cherche un code CIM-10 pour un texte donné.
Utilise le dictionnaire complet (10 893 codes) avec CIM10_MAP en override prioritaire.
"""
return dict_lookup(text, domain_overrides=CIM10_MAP)
def _is_abnormal(test: str, value: str) -> bool | None:
@@ -616,6 +668,10 @@ def _is_abnormal(test: str, value: str) -> bool | None:
"GGT": (0, 60),
"PAL": (0, 150),
"Bilirubine totale": (0, 17),
"Hémoglobine": (12, 17),
"Plaquettes": (150, 400),
"Leucocytes": (4, 10),
"Créatinine": (50, 120),
}
if test in normals: