feat(referentials): validation ATIH 2018 des codes médicaux
Ajoute une couche de validation post-extraction contre les référentiels
officiels de l'ATIH (Agence Technique de l'Information sur
l'Hospitalisation) pour 2018. Zéro tolérance sur les codes T2A : un
code invalide est flaggé, et une correction par plus proche voisin
(Levenshtein ≤ 1) est proposée.
Contenu :
- pipeline/referentials.py : API publique is_valid_{cim10,ccam,ghm,ghs},
get_cim10_libelle, nearest_cim10, ghm_to_ghs. CLI --build/--test/--stats.
- pipeline/validation.py : annote un JSON d'extraction avec un bloc
`_validation` par page (codes valides/invalides + suggestions + cross-
checks GHM↔GHS).
- referentials/sources/ : données brutes ATIH publiques (CIM-10 ClaML
2019 substitut, CCAM v5 2018, GHM v2018, tarifs fév. 2018).
- referentials/atih_2018.sqlite : base SQLite prête à l'emploi
(11 623 CIM-10 · 8 147 CCAM · 2 593 GHM · 5 329 couples GHM→GHS).
- tests/test_referentials.py : 11 tests unitaires (11/11 passent).
- annotate_validation.py : script qui annote tous les JSONs V2 en
place et produit validation_report.md.
Note CIM-10 : la version 2018 ATIH n'est publiée qu'en PDF, ClaML 2019
est utilisée en substitut (écart connu ≈ 60 codes / 11 600).
Gestion des suffixes PMSI : `*` (CMA exclue par le DP) et `+N`
(extension PMSI) sont strippés avant validation, le code racine seul
est comparé au référentiel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
217
pipeline/validation.py
Normal file
217
pipeline/validation.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Validation ATIH des codes extraits.
|
||||
|
||||
Prend un JSON d'extraction produit par `pipeline/extract.py` et l'enrichit
|
||||
d'une section `_validation` par champ de code médical (CIM-10, CCAM, GHM, GHS)
|
||||
avec :
|
||||
|
||||
- `valid` : le code existe dans le référentiel ATIH 2018
|
||||
- `suggestion` : si invalide, code le plus proche par Levenshtein ≤ 1 (CIM-10)
|
||||
- `libelle_ref` : libellé officiel ATIH (CIM-10) pour audit
|
||||
|
||||
Plus des cross-checks (GHS ∈ ghm_to_ghs(GHM)) pour détecter des incohérences
|
||||
de groupage.
|
||||
|
||||
Principes :
|
||||
- Lecture seule sur le JSON source — on produit une COPIE enrichie.
|
||||
- Ne supprime / ne corrige RIEN automatiquement ; seule une suggestion est
|
||||
annotée. La correction reste à la discrétion d'un humain (overlay) ou d'un
|
||||
prochain pass automatique.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from .referentials import (
|
||||
get_cim10_libelle,
|
||||
ghm_to_ghs,
|
||||
is_valid_ccam,
|
||||
is_valid_cim10,
|
||||
is_valid_ghm,
|
||||
is_valid_ghs,
|
||||
nearest_cim10,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def _check_cim10(code: str) -> dict:
|
||||
"""Valide un code CIM-10 et suggère une correction si invalide."""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return {"code": "", "valid": None}
|
||||
valid = is_valid_cim10(code)
|
||||
entry = {"code": code, "valid": valid}
|
||||
if valid:
|
||||
entry["libelle_ref"] = get_cim10_libelle(code)
|
||||
else:
|
||||
sug = nearest_cim10(code, max_distance=1)
|
||||
if sug:
|
||||
entry["suggestion"] = sug
|
||||
entry["suggestion_libelle"] = get_cim10_libelle(sug)
|
||||
return entry
|
||||
|
||||
|
||||
def _check_ccam(code: str) -> dict:
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return {"code": "", "valid": None}
|
||||
return {"code": code, "valid": is_valid_ccam(code)}
|
||||
|
||||
|
||||
def _check_ghm(code: str) -> dict:
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return {"code": "", "valid": None}
|
||||
entry = {"code": code, "valid": is_valid_ghm(code)}
|
||||
if entry["valid"]:
|
||||
entry["ghs_possibles"] = ghm_to_ghs(code)
|
||||
return entry
|
||||
|
||||
|
||||
def _check_ghs(code: str) -> dict:
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return {"code": "", "valid": None}
|
||||
return {"code": code, "valid": is_valid_ghs(code)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Validation d'un bloc codage (etab ou reco)
|
||||
# ============================================================
|
||||
|
||||
def _validate_codage(codage: dict) -> dict:
|
||||
"""Valide un bloc codage_etab ou codage_reco."""
|
||||
if not isinstance(codage, dict):
|
||||
return {}
|
||||
out = {
|
||||
"dp": _check_cim10(codage.get("dp", "")),
|
||||
"dr": _check_cim10(codage.get("dr", "")),
|
||||
}
|
||||
das_list = codage.get("das") or []
|
||||
if isinstance(das_list, list):
|
||||
out["das"] = [_check_cim10(d.get("code", "")) if isinstance(d, dict) else _check_cim10(str(d))
|
||||
for d in das_list]
|
||||
return out
|
||||
|
||||
|
||||
def _validate_actes(actes: Any) -> list[dict]:
|
||||
if not isinstance(actes, list):
|
||||
return []
|
||||
return [_check_ccam(a.get("code", "")) if isinstance(a, dict) else _check_ccam(str(a))
|
||||
for a in actes]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cross-checks GHM ↔ GHS
|
||||
# ============================================================
|
||||
|
||||
def _cross_check_ghm_ghs(ghm: str, ghs: str) -> dict:
|
||||
"""Vérifie qu'un GHS observé est listé parmi les GHS possibles du GHM."""
|
||||
ghm = (ghm or "").strip()
|
||||
ghs = (ghs or "").strip()
|
||||
if not ghm or not ghs:
|
||||
return {"checked": False, "reason": "ghm ou ghs manquant"}
|
||||
if not is_valid_ghm(ghm):
|
||||
return {"checked": False, "reason": "GHM invalide"}
|
||||
possibles = ghm_to_ghs(ghm)
|
||||
# Normalisation simple : on compare la fin (au cas où l'un est tronqué)
|
||||
ok = ghs in possibles or any(p.endswith(ghs) or ghs.endswith(p) for p in possibles)
|
||||
return {
|
||||
"checked": True,
|
||||
"coherent": ok,
|
||||
"ghs_extrait": ghs,
|
||||
"ghs_possibles": possibles,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Point d'entrée
|
||||
# ============================================================
|
||||
|
||||
def validate_recueil(recueil: dict) -> dict:
|
||||
"""Retourne un dict résumé des validations pour la page recueil."""
|
||||
v = {
|
||||
"codage_etab": _validate_codage(recueil.get("codage_etab", {})),
|
||||
"codage_reco": _validate_codage(recueil.get("codage_reco", {})),
|
||||
"actes_etab": _validate_actes(recueil.get("actes_etab", [])),
|
||||
"actes_reco": _validate_actes(recueil.get("actes_reco", [])),
|
||||
"ghm_etab": _check_ghm(recueil.get("ghm_etab", "")),
|
||||
"ghs_etab": _check_ghs(recueil.get("ghs_etab", "")),
|
||||
"ghm_reco": _check_ghm(recueil.get("ghm_reco", "")),
|
||||
"ghs_reco": _check_ghs(recueil.get("ghs_reco", "")),
|
||||
"cross_checks": {
|
||||
"etab": _cross_check_ghm_ghs(
|
||||
recueil.get("ghm_etab", ""), recueil.get("ghs_etab", "")),
|
||||
"reco": _cross_check_ghm_ghs(
|
||||
recueil.get("ghm_reco", ""), recueil.get("ghs_reco", "")),
|
||||
},
|
||||
}
|
||||
v["summary"] = _summarize(v)
|
||||
return v
|
||||
|
||||
|
||||
def _summarize(validation: dict) -> dict:
|
||||
"""Compte les codes valides / invalides dans une section _validation."""
|
||||
valid, invalid, empty = 0, 0, 0
|
||||
|
||||
def _count_entry(e):
|
||||
nonlocal valid, invalid, empty
|
||||
if e.get("valid") is True: valid += 1
|
||||
elif e.get("valid") is False: invalid += 1
|
||||
else: empty += 1
|
||||
|
||||
for section in ("codage_etab", "codage_reco"):
|
||||
sec = validation.get(section, {}) or {}
|
||||
_count_entry(sec.get("dp", {}))
|
||||
_count_entry(sec.get("dr", {}))
|
||||
for d in sec.get("das", []) or []:
|
||||
_count_entry(d)
|
||||
for actes_key in ("actes_etab", "actes_reco"):
|
||||
for a in validation.get(actes_key, []) or []:
|
||||
_count_entry(a)
|
||||
for g in ("ghm_etab", "ghs_etab", "ghm_reco", "ghs_reco"):
|
||||
_count_entry(validation.get(g, {}))
|
||||
|
||||
cc = validation.get("cross_checks", {})
|
||||
incoherent = sum(1 for v in cc.values() if v.get("checked") and not v.get("coherent"))
|
||||
return {
|
||||
"valid": valid, "invalid": invalid, "empty": empty,
|
||||
"total_codes": valid + invalid,
|
||||
"ghm_ghs_incoherents": incoherent,
|
||||
}
|
||||
|
||||
|
||||
def annotate(extraction: dict) -> dict:
|
||||
"""Annote un JSON d'extraction complet avec validation ATIH.
|
||||
|
||||
Retourne une COPIE enrichie d'un bloc `_validation` à la racine de chaque
|
||||
page structurée. N'efface / ne corrige aucune valeur.
|
||||
"""
|
||||
out = deepcopy(extraction)
|
||||
ext = out.get("extraction") or {}
|
||||
if "recueil" in ext and isinstance(ext["recueil"], dict):
|
||||
ext["recueil"]["_validation"] = validate_recueil(ext["recueil"])
|
||||
# Concertation 2 : valider les 3 GHS
|
||||
if "concertation_2" in ext and isinstance(ext["concertation_2"], dict):
|
||||
c2 = ext["concertation_2"]
|
||||
c2["_validation"] = {
|
||||
"ghs_initial": _check_ghs(c2.get("ghs_initial", "")),
|
||||
"ghs_avant_concertation": _check_ghs(c2.get("ghs_avant_concertation", "")),
|
||||
"ghs_final": _check_ghs(c2.get("ghs_final", "")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test rapide sur OGC 7
|
||||
import json, sys
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "output/v2/OGC 7.json"
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
annotated = annotate(data)
|
||||
rec_v = annotated["extraction"]["recueil"]["_validation"]
|
||||
print(json.dumps(rec_v["summary"], indent=2))
|
||||
print("\ncross_checks:", json.dumps(rec_v["cross_checks"], indent=2, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user