feat: enrichissement CIM-10 sous-codes + normes biologiques dans prompt DAS
Piste 1 : ajout de cim10_supplements.json (40 sous-codes E10/E11/E13/F10) fusionné au chargement par load_dict() — E11.9 et autres ne sont plus rejetés. Piste 2 : export BIO_NORMALS depuis cim10_extractor, inclusion des plages de référence [N: min-max] dans le contexte LLM et règle explicite dans le prompt DAS pour éviter les hallucinations sur valeurs biologiques normales. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
data/cim10_supplements.json
Normal file
42
data/cim10_supplements.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"E10.0": "Diabète de type 1, avec coma",
|
||||||
|
"E10.1": "Diabète de type 1, avec acidocétose",
|
||||||
|
"E10.2": "Diabète de type 1, avec complications rénales",
|
||||||
|
"E10.3": "Diabète de type 1, avec complications ophtalmologiques",
|
||||||
|
"E10.4": "Diabète de type 1, avec complications neurologiques",
|
||||||
|
"E10.5": "Diabète de type 1, avec complications vasculaires périphériques",
|
||||||
|
"E10.6": "Diabète de type 1, avec autres complications précisées",
|
||||||
|
"E10.7": "Diabète de type 1, avec complications multiples",
|
||||||
|
"E10.8": "Diabète de type 1, avec complications non précisées",
|
||||||
|
"E10.9": "Diabète de type 1, sans complication",
|
||||||
|
"E11.0": "Diabète de type 2, avec coma",
|
||||||
|
"E11.1": "Diabète de type 2, avec acidocétose",
|
||||||
|
"E11.2": "Diabète de type 2, avec complications rénales",
|
||||||
|
"E11.3": "Diabète de type 2, avec complications ophtalmologiques",
|
||||||
|
"E11.4": "Diabète de type 2, avec complications neurologiques",
|
||||||
|
"E11.5": "Diabète de type 2, avec complications vasculaires périphériques",
|
||||||
|
"E11.6": "Diabète de type 2, avec autres complications précisées",
|
||||||
|
"E11.7": "Diabète de type 2, avec complications multiples",
|
||||||
|
"E11.8": "Diabète de type 2, avec complications non précisées",
|
||||||
|
"E11.9": "Diabète de type 2, sans complication",
|
||||||
|
"E13.0": "Autres diabètes sucrés précisés, avec coma",
|
||||||
|
"E13.1": "Autres diabètes sucrés précisés, avec acidocétose",
|
||||||
|
"E13.2": "Autres diabètes sucrés précisés, avec complications rénales",
|
||||||
|
"E13.3": "Autres diabètes sucrés précisés, avec complications ophtalmologiques",
|
||||||
|
"E13.4": "Autres diabètes sucrés précisés, avec complications neurologiques",
|
||||||
|
"E13.5": "Autres diabètes sucrés précisés, avec complications vasculaires périphériques",
|
||||||
|
"E13.6": "Autres diabètes sucrés précisés, avec autres complications précisées",
|
||||||
|
"E13.7": "Autres diabètes sucrés précisés, avec complications multiples",
|
||||||
|
"E13.8": "Autres diabètes sucrés précisés, avec complications non précisées",
|
||||||
|
"E13.9": "Autres diabètes sucrés précisés, sans complication",
|
||||||
|
"F10.0": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, intoxication aiguë",
|
||||||
|
"F10.1": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, utilisation nocive pour la santé",
|
||||||
|
"F10.2": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de dépendance",
|
||||||
|
"F10.3": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de sevrage",
|
||||||
|
"F10.4": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de sevrage avec delirium",
|
||||||
|
"F10.5": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble psychotique",
|
||||||
|
"F10.6": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome amnésique",
|
||||||
|
"F10.7": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble résiduel ou psychotique de survenue tardive",
|
||||||
|
"F10.8": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, autres troubles mentaux et du comportement",
|
||||||
|
"F10.9": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble mental ou du comportement, sans précision"
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ REFERENTIELS_DIR = BASE_DIR / "data" / "referentiels"
|
|||||||
UPLOAD_MAX_SIZE_MB = 50
|
UPLOAD_MAX_SIZE_MB = 50
|
||||||
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
|
ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"}
|
||||||
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
|
CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json"
|
||||||
|
CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json"
|
||||||
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
|
CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json"
|
||||||
CIM10_PDF = Path("/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf")
|
CIM10_PDF = Path("/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf")
|
||||||
GUIDE_METHODO_PDF = Path("/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf")
|
GUIDE_METHODO_PDF = Path("/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import unicodedata
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..config import CIM10_DICT_PATH, RAG_INDEX_DIR
|
from ..config import CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH, RAG_INDEX_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ def load_dict() -> dict[str, str]:
|
|||||||
"""Charge le dictionnaire CIM-10 (singleton lazy-loaded).
|
"""Charge le dictionnaire CIM-10 (singleton lazy-loaded).
|
||||||
|
|
||||||
Si le fichier JSON n'existe pas, tente de le construire depuis metadata.json.
|
Si le fichier JSON n'existe pas, tente de le construire depuis metadata.json.
|
||||||
|
Fusionne ensuite les suppléments (sous-codes manquants) sans écraser le dict principal.
|
||||||
"""
|
"""
|
||||||
global _dict_cache
|
global _dict_cache
|
||||||
if _dict_cache is not None:
|
if _dict_cache is not None:
|
||||||
@@ -102,6 +103,18 @@ def load_dict() -> dict[str, str]:
|
|||||||
logger.info("Dictionnaire CIM-10 absent, construction depuis metadata.json...")
|
logger.info("Dictionnaire CIM-10 absent, construction depuis metadata.json...")
|
||||||
_dict_cache = build_dict()
|
_dict_cache = build_dict()
|
||||||
|
|
||||||
|
# Fusionner les suppléments (ne remplace pas les entrées existantes)
|
||||||
|
if CIM10_SUPPLEMENTS_PATH.exists():
|
||||||
|
with open(CIM10_SUPPLEMENTS_PATH, encoding="utf-8") as f:
|
||||||
|
supplements = json.load(f)
|
||||||
|
added = 0
|
||||||
|
for code, label in supplements.items():
|
||||||
|
if code not in _dict_cache:
|
||||||
|
_dict_cache[code] = label
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
logger.info("Suppléments CIM-10 : %d codes ajoutés depuis %s", added, CIM10_SUPPLEMENTS_PATH.name)
|
||||||
|
|
||||||
return _dict_cache
|
return _dict_cache
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -873,18 +873,9 @@ def _lookup_cim10(text: str) -> str | None:
|
|||||||
return dict_lookup(text, domain_overrides=CIM10_MAP)
|
return dict_lookup(text, domain_overrides=CIM10_MAP)
|
||||||
|
|
||||||
|
|
||||||
def _is_abnormal(test: str, value: str) -> bool | None:
|
# Plages de référence biologiques (min, max) — utilisées par _is_abnormal()
|
||||||
"""Détermine si un résultat biologique est anormal."""
|
# et exportées pour le formatage du contexte LLM dans rag_search.py
|
||||||
try:
|
BIO_NORMALS: dict[str, tuple[float, float]] = {
|
||||||
val = float(value.replace(",", "."))
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
if value.lower() in ("négative", "negative", "normale", "normal"):
|
|
||||||
return False
|
|
||||||
if value.lower() in ("positive", "positif", "élevée", "elevee"):
|
|
||||||
return True
|
|
||||||
return None
|
|
||||||
|
|
||||||
normals: dict[str, tuple[float, float]] = {
|
|
||||||
"Lipasémie": (0, 60),
|
"Lipasémie": (0, 60),
|
||||||
"CRP": (0, 5),
|
"CRP": (0, 5),
|
||||||
"ASAT": (0, 40),
|
"ASAT": (0, 40),
|
||||||
@@ -896,9 +887,21 @@ def _is_abnormal(test: str, value: str) -> bool | None:
|
|||||||
"Plaquettes": (150, 400),
|
"Plaquettes": (150, 400),
|
||||||
"Leucocytes": (4, 10),
|
"Leucocytes": (4, 10),
|
||||||
"Créatinine": (50, 120),
|
"Créatinine": (50, 120),
|
||||||
}
|
}
|
||||||
|
|
||||||
if test in normals:
|
|
||||||
lo, hi = normals[test]
|
def _is_abnormal(test: str, value: str) -> bool | None:
|
||||||
|
"""Détermine si un résultat biologique est anormal."""
|
||||||
|
try:
|
||||||
|
val = float(value.replace(",", "."))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
if value.lower() in ("négative", "negative", "normale", "normal"):
|
||||||
|
return False
|
||||||
|
if value.lower() in ("positive", "positif", "élevée", "elevee"):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
if test in BIO_NORMALS:
|
||||||
|
lo, hi = BIO_NORMALS[test]
|
||||||
return val > hi or val < lo
|
return val > hi or val < lo
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ..config import (
|
|||||||
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
|
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
|
||||||
)
|
)
|
||||||
from .cim10_dict import normalize_code, validate_code as cim10_validate
|
from .cim10_dict import normalize_code, validate_code as cim10_validate
|
||||||
|
from .cim10_extractor import BIO_NORMALS
|
||||||
from .ccam_dict import validate_code as ccam_validate
|
from .ccam_dict import validate_code as ccam_validate
|
||||||
from .ollama_client import call_ollama, parse_json_response
|
from .ollama_client import call_ollama, parse_json_response
|
||||||
from .ollama_cache import OllamaCache
|
from .ollama_cache import OllamaCache
|
||||||
@@ -166,8 +167,15 @@ def _format_contexte(contexte: dict) -> str:
|
|||||||
bio_parts = []
|
bio_parts = []
|
||||||
for b in biologie:
|
for b in biologie:
|
||||||
test, valeur, anomalie = b if isinstance(b, (list, tuple)) else (b.get("test"), b.get("valeur"), b.get("anomalie"))
|
test, valeur, anomalie = b if isinstance(b, (list, tuple)) else (b.get("test"), b.get("valeur"), b.get("anomalie"))
|
||||||
|
# Ajouter la plage de référence si connue
|
||||||
|
norme_str = ""
|
||||||
|
if test in BIO_NORMALS:
|
||||||
|
lo, hi = BIO_NORMALS[test]
|
||||||
|
lo_s = int(lo) if lo == int(lo) else lo
|
||||||
|
hi_s = int(hi) if hi == int(hi) else hi
|
||||||
|
norme_str = f" [N: {lo_s}-{hi_s}]"
|
||||||
marker = " (\u2191)" if anomalie else ""
|
marker = " (\u2191)" if anomalie else ""
|
||||||
bio_parts.append(f"{test} {valeur}{marker}")
|
bio_parts.append(f"{test} {valeur}{norme_str}{marker}")
|
||||||
lines.append(f"- Biologie : {', '.join(bio_parts)}")
|
lines.append(f"- Biologie : {', '.join(bio_parts)}")
|
||||||
|
|
||||||
imagerie = contexte.get("imagerie")
|
imagerie = contexte.get("imagerie")
|
||||||
@@ -489,6 +497,7 @@ RÈGLES IMPÉRATIVES :
|
|||||||
- Ne PAS coder les antécédents non pertinents pour le séjour
|
- Ne PAS coder les antécédents non pertinents pour le séjour
|
||||||
- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère)
|
- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère)
|
||||||
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
|
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
|
||||||
|
- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale.
|
||||||
|
|
||||||
DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"}
|
DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"}
|
||||||
|
|
||||||
|
|||||||
75
tests/test_cim10_supplements.py
Normal file
75
tests/test_cim10_supplements.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tests pour la fusion des suppléments CIM-10 dans cim10_dict."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.medical.cim10_dict import load_dict, validate_code, reset_cache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset():
|
||||||
|
"""Reset le cache singleton avant/après chaque test."""
|
||||||
|
reset_cache()
|
||||||
|
yield
|
||||||
|
reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCim10Supplements:
|
||||||
|
"""Tests pour la fusion des sous-codes supplémentaires."""
|
||||||
|
|
||||||
|
def test_e11_9_valid(self):
|
||||||
|
"""E11.9 (diabète type 2 sans complication) est reconnu."""
|
||||||
|
is_valid, label = validate_code("E11.9")
|
||||||
|
assert is_valid
|
||||||
|
assert "diabète" in label.lower() or "type 2" in label.lower()
|
||||||
|
|
||||||
|
def test_e10_9_valid(self):
|
||||||
|
"""E10.9 (diabète type 1 sans complication) est reconnu."""
|
||||||
|
is_valid, label = validate_code("E10.9")
|
||||||
|
assert is_valid
|
||||||
|
assert "type 1" in label.lower()
|
||||||
|
|
||||||
|
def test_f10_2_valid(self):
|
||||||
|
"""F10.2 (alcool, syndrome de dépendance) est reconnu."""
|
||||||
|
is_valid, label = validate_code("F10.2")
|
||||||
|
assert is_valid
|
||||||
|
assert "alcool" in label.lower()
|
||||||
|
|
||||||
|
def test_e13_9_valid(self):
|
||||||
|
"""E13.9 (autres diabètes sans complication) est reconnu."""
|
||||||
|
is_valid, label = validate_code("E13.9")
|
||||||
|
assert is_valid
|
||||||
|
|
||||||
|
def test_main_dict_not_overwritten(self):
|
||||||
|
"""Les entrées du dict principal ne sont PAS écrasées par les suppléments."""
|
||||||
|
d = load_dict()
|
||||||
|
# E11 existe dans le dict principal avec un label issu de FAISS
|
||||||
|
assert "E11" in d
|
||||||
|
# Le label doit venir du dict principal, pas des suppléments
|
||||||
|
# (les suppléments ne contiennent que E11.0-E11.9, pas E11)
|
||||||
|
|
||||||
|
def test_supplements_file_missing(self, tmp_path):
|
||||||
|
"""load_dict() fonctionne normalement si le fichier suppléments n'existe pas."""
|
||||||
|
fake_supplements = tmp_path / "nonexistent.json"
|
||||||
|
with patch("src.medical.cim10_dict.CIM10_SUPPLEMENTS_PATH", fake_supplements):
|
||||||
|
d = load_dict()
|
||||||
|
assert len(d) > 0 # Le dict principal est chargé
|
||||||
|
|
||||||
|
def test_supplement_codes_count(self):
|
||||||
|
"""Les suppléments ajoutent les sous-codes attendus."""
|
||||||
|
d = load_dict()
|
||||||
|
# Vérifier que E11.0 à E11.9 existent tous
|
||||||
|
for i in range(10):
|
||||||
|
code = f"E11.{i}"
|
||||||
|
assert code in d, f"{code} manquant du dictionnaire"
|
||||||
|
|
||||||
|
def test_validate_code_normalized(self):
|
||||||
|
"""validate_code normalise le format (e119 → E11.9)."""
|
||||||
|
is_valid, label = validate_code("e119")
|
||||||
|
assert is_valid
|
||||||
|
assert label # Label non vide
|
||||||
@@ -126,6 +126,65 @@ class TestExtractDasLlm:
|
|||||||
assert "Patient hypertendu diabétique" in prompt
|
assert "Patient hypertendu diabétique" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestBioNormesInContext:
|
||||||
|
"""Tests pour l'inclusion des normes biologiques dans le contexte LLM."""
|
||||||
|
|
||||||
|
def test_format_contexte_includes_normes(self):
|
||||||
|
"""_format_contexte() affiche les normes [N: min-max] pour chaque résultat bio."""
|
||||||
|
from src.medical.rag_search import _format_contexte
|
||||||
|
|
||||||
|
contexte = {
|
||||||
|
"biologie_cle": [
|
||||||
|
("Créatinine", "76", False),
|
||||||
|
("CRP", "250", True),
|
||||||
|
("Lipasémie", "1200", True),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = _format_contexte(contexte)
|
||||||
|
assert "[N: 50-120]" in result
|
||||||
|
assert "[N: 0-5]" in result
|
||||||
|
assert "[N: 0-60]" in result
|
||||||
|
# Créatinine normale → pas de marqueur ↑
|
||||||
|
assert "Créatinine 76 [N: 50-120]" in result
|
||||||
|
# CRP anormale → marqueur ↑
|
||||||
|
assert "CRP 250 [N: 0-5] (↑)" in result
|
||||||
|
|
||||||
|
def test_format_contexte_no_norme_for_unknown_test(self):
|
||||||
|
"""Les tests sans norme connue n'affichent pas de [N: ...]."""
|
||||||
|
from src.medical.rag_search import _format_contexte
|
||||||
|
|
||||||
|
contexte = {
|
||||||
|
"biologie_cle": [
|
||||||
|
("Test inconnu", "42", None),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = _format_contexte(contexte)
|
||||||
|
assert "Test inconnu 42" in result
|
||||||
|
assert "[N:" not in result
|
||||||
|
|
||||||
|
def test_prompt_das_includes_bio_norms_rule(self):
|
||||||
|
"""Le prompt DAS contient la règle sur les normes biologiques."""
|
||||||
|
from src.medical.rag_search import _build_prompt_das_extraction
|
||||||
|
|
||||||
|
prompt = _build_prompt_das_extraction(
|
||||||
|
text="Patient avec créatinine normale",
|
||||||
|
contexte={"biologie_cle": [("Créatinine", "76", False)]},
|
||||||
|
existing_das=[],
|
||||||
|
dp_texte="Pancréatite aiguë",
|
||||||
|
)
|
||||||
|
assert "ATTENTION aux valeurs biologiques" in prompt
|
||||||
|
assert "[N: min-max]" in prompt
|
||||||
|
|
||||||
|
def test_bio_normals_exported(self):
|
||||||
|
"""BIO_NORMALS est bien exporté depuis cim10_extractor."""
|
||||||
|
from src.medical.cim10_extractor import BIO_NORMALS
|
||||||
|
|
||||||
|
assert "Créatinine" in BIO_NORMALS
|
||||||
|
assert BIO_NORMALS["Créatinine"] == (50, 120)
|
||||||
|
assert "CRP" in BIO_NORMALS
|
||||||
|
assert BIO_NORMALS["CRP"] == (0, 5)
|
||||||
|
|
||||||
|
|
||||||
class TestExtractDasLlmIntegration:
|
class TestExtractDasLlmIntegration:
|
||||||
"""Tests d'intégration pour le pass LLM DAS dans cim10_extractor.py."""
|
"""Tests d'intégration pour le pass LLM DAS dans cim10_extractor.py."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user