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:
@@ -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+(né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:
|
||||
|
||||
Reference in New Issue
Block a user