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>
137 lines
6.0 KiB
Python
137 lines
6.0 KiB
Python
"""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)
|