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:
136
annotate_validation.py
Normal file
136
annotate_validation.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Annote les JSONs V2 existants avec la validation ATIH.
|
||||
|
||||
Utile pour ajouter la validation sans relancer l'extraction complète.
|
||||
Produit aussi un rapport agrégé en markdown.
|
||||
"""
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from pipeline.validation import annotate
|
||||
|
||||
|
||||
OUT_DIR = Path("output/v2")
|
||||
REPORT = Path("validation_report.md")
|
||||
|
||||
|
||||
def annotate_all() -> list[dict]:
|
||||
"""Annote chaque JSON et écrit le résultat en place (avec _validation)."""
|
||||
results = []
|
||||
for p in sorted(OUT_DIR.glob("OGC *.json")):
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
annotated = annotate(data)
|
||||
p.write_text(json.dumps(annotated, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
results.append(annotated)
|
||||
rec_v = annotated.get("extraction", {}).get("recueil", {}).get("_validation", {})
|
||||
s = rec_v.get("summary", {})
|
||||
cc = rec_v.get("cross_checks", {})
|
||||
print(f" {data['fichier']:8s} — valid={s.get('valid',0):2d} invalid={s.get('invalid',0):2d} "
|
||||
f"empty={s.get('empty',0):2d} incoherent={s.get('ghm_ghs_incoherents',0)} "
|
||||
f"etab={cc.get('etab',{}).get('coherent','?')} reco={cc.get('reco',{}).get('coherent','?')}")
|
||||
return results
|
||||
|
||||
|
||||
def build_report(results: list[dict]):
|
||||
"""Agrégation par champ : taux de validité, suggestions les plus fréquentes."""
|
||||
per_field = defaultdict(lambda: {"total": 0, "valid": 0, "invalid": 0, "empty": 0, "suggestions": []})
|
||||
incoherences = []
|
||||
|
||||
for d in results:
|
||||
name = d["fichier"]
|
||||
rec_v = d.get("extraction", {}).get("recueil", {}).get("_validation", {})
|
||||
if not rec_v:
|
||||
continue
|
||||
|
||||
# Codes unitaires
|
||||
for key in ["ghm_etab", "ghs_etab", "ghm_reco", "ghs_reco"]:
|
||||
entry = rec_v.get(key, {})
|
||||
st = per_field[key]
|
||||
st["total"] += 1
|
||||
if entry.get("valid") is True: st["valid"] += 1
|
||||
elif entry.get("valid") is False:
|
||||
st["invalid"] += 1
|
||||
if "suggestion" in entry:
|
||||
st["suggestions"].append((name, entry["code"], entry["suggestion"]))
|
||||
else: st["empty"] += 1
|
||||
|
||||
# Codage etab / reco : dp + dr + das
|
||||
for section in ["codage_etab", "codage_reco"]:
|
||||
sec = rec_v.get(section, {})
|
||||
for sub in ["dp", "dr"]:
|
||||
entry = sec.get(sub, {})
|
||||
st = per_field[f"{section}.{sub}"]
|
||||
st["total"] += 1
|
||||
if entry.get("valid") is True: st["valid"] += 1
|
||||
elif entry.get("valid") is False:
|
||||
st["invalid"] += 1
|
||||
if "suggestion" in entry:
|
||||
st["suggestions"].append((name, entry["code"], entry["suggestion"]))
|
||||
else: st["empty"] += 1
|
||||
for das in sec.get("das", []) or []:
|
||||
st = per_field[f"{section}.das"]
|
||||
st["total"] += 1
|
||||
if das.get("valid") is True: st["valid"] += 1
|
||||
elif das.get("valid") is False:
|
||||
st["invalid"] += 1
|
||||
if "suggestion" in das:
|
||||
st["suggestions"].append((name, das["code"], das["suggestion"]))
|
||||
else: st["empty"] += 1
|
||||
|
||||
# Cohérence GHM ↔ GHS
|
||||
for side in ["etab", "reco"]:
|
||||
cc = rec_v.get("cross_checks", {}).get(side, {})
|
||||
if cc.get("checked") and not cc.get("coherent"):
|
||||
incoherences.append({
|
||||
"dossier": name, "side": side,
|
||||
"ghs_extrait": cc.get("ghs_extrait"),
|
||||
"ghs_possibles": cc.get("ghs_possibles"),
|
||||
})
|
||||
|
||||
# Markdown report
|
||||
lines = ["# Rapport de validation ATIH — V2 (18 dossiers)\n"]
|
||||
lines.append("## Couverture et validité par champ\n")
|
||||
lines.append("| Champ | Total | Valid | Invalid | Vide | Validité codes renseignés |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|")
|
||||
for f, st in per_field.items():
|
||||
renseignes = st["valid"] + st["invalid"]
|
||||
ratio = (100 * st["valid"] / renseignes) if renseignes else 0
|
||||
lines.append(f"| `{f}` | {st['total']} | {st['valid']} | {st['invalid']} | {st['empty']} | {ratio:.0f}% |")
|
||||
|
||||
# Suggestions OCR
|
||||
lines.append("\n## Corrections OCR suggérées (Levenshtein ≤ 1)")
|
||||
lines.append("\nCodes extraits invalides mais ressemblant à un code ATIH existant :\n")
|
||||
lines.append("| Dossier | Champ | Code extrait | Suggestion |")
|
||||
lines.append("|---|---|---|---|")
|
||||
sugg_count = 0
|
||||
for field, st in per_field.items():
|
||||
for name, code, sug in st["suggestions"]:
|
||||
lines.append(f"| {name} | `{field}` | `{code}` | **`{sug}`** |")
|
||||
sugg_count += 1
|
||||
if sugg_count == 0:
|
||||
lines.append("| — | — | — | Aucune suggestion (pas de correction Levenshtein ≤ 1) |")
|
||||
|
||||
# Incohérences GHM ↔ GHS
|
||||
lines.append("\n## Incohérences GHM ↔ GHS détectées\n")
|
||||
if incoherences:
|
||||
lines.append("| Dossier | Côté | GHS extrait | GHS possibles pour le GHM |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for inc in incoherences:
|
||||
lines.append(f"| {inc['dossier']} | {inc['side']} | `{inc['ghs_extrait']}` | {inc['ghs_possibles']} |")
|
||||
else:
|
||||
lines.append("✓ Aucune incohérence détectée sur les GHM/GHS extraits.")
|
||||
|
||||
lines.append(f"\n## Synthèse\n")
|
||||
total_codes = sum(st["valid"] + st["invalid"] for st in per_field.values())
|
||||
total_valid = sum(st["valid"] for st in per_field.values())
|
||||
lines.append(f"- **{total_valid}/{total_codes} codes valides** ({100*total_valid/total_codes:.1f}%)")
|
||||
lines.append(f"- **{sugg_count} suggestions de correction OCR** trouvées automatiquement")
|
||||
lines.append(f"- **{len(incoherences)} incohérences GHM↔GHS** sur les paires extraites")
|
||||
|
||||
REPORT.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"\nRapport → {REPORT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Annotation en place des JSONs V2 + calcul validation ATIH...\n")
|
||||
results = annotate_all()
|
||||
build_report(results)
|
||||
Reference in New Issue
Block a user