Compare commits

...

10 Commits

Author SHA1 Message Date
Dom
e55daf275e fix(ui): shim de compatibilité pour streamlit-drawable-canvas 0.9.3
streamlit-drawable-canvas 0.9.3 (dernière version disponible sur PyPI)
utilise l'API privée `streamlit.elements.image.image_to_url` qui a été
retirée à partir de Streamlit ≈ 1.49. Sur Streamlit 1.56 (installé ici),
le canvas plante à l'ouverture du mode "🔧 Calibration zones" :

  AttributeError: module 'streamlit.elements.image' has no attribute 'image_to_url'

Plutôt que de downgrader Streamlit globalement (impact sur les autres
features de l'overlay), on injecte une implémentation locale de
`image_to_url` au tout début de pipeline/ui_overlay.py si elle est absente.
L'implémentation produit un data URI base64 que le canvas consomme
directement côté navigateur, sans toucher au système de fichiers media.

À retirer dès qu'une version > 0.9.3 de streamlit-drawable-canvas
publiera un correctif officiel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:52:19 +02:00
Dom
3a87751444 test: couvrir les modules purs du pipeline (96 nouveaux tests)
Suite de tests unitaires pour tous les modules pipeline qui ne dépendent
pas du VLM — utiles pour garantir la non-régression après refactor et
servir de spec vivante de chaque fonction.

Fichiers :
- tests/test_json_utils.py   (20 tests) : parse_json_output + toutes les
  stratégies de récupération (fences, virgules manquantes, boucles vides,
  fermeture JSON, fallback _raw/_parse_error)
- tests/test_deskew.py       (11 tests) : détection Hough + correction,
  image synthétique + fixtures cache réel
- tests/test_checkboxes.py   (17 tests) : parse_ghs_injustifie,
  dark_ratio, inner_frac, et ground truth visuel sur 17 dossiers
  (mapping hash→OGC résolu au runtime pour éviter les constantes fragiles)
- tests/test_validation.py   (18 tests) : _check_cim10/ccam/ghm/ghs,
  cross-checks GHM↔GHS, annotate sur JSON vide et complet,
  preservation de l'input (copie défensive)
- tests/test_schema.py       (8 tests)  : clean_dossier retire les champs
  debug, préserve les champs métier, compacte la validation, ne modifie
  pas l'input
- tests/test_zones_config.py (8 tests)  : load/save round-trip, merge
  avec defaults, résilience JSON corrompu, get_zone

Total : 107 tests, 5.1 s d'exécution, tous passent. Aucune dépendance
GPU, s'exécutent en CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:29:23 +02:00
Dom
d326524e49 refactor(extract): décomposer en étages testables (json_utils + recueil)
extract.py contenait 4 responsabilités mélangées (320 lignes) :
parsing JSON tolérant, résolution de zones, crop Recodage avec classification
métier, orchestration. Séparation en modules cohérents :

- pipeline/json_utils.py : parsing tolérant réutilisable (strip fences,
  virgules manquantes, troncature des boucles d'objets vides, fermeture
  des structures JSON ouvertes). N'a aucune connaissance métier OGC.

- pipeline/recueil.py    : toute la logique spécifique à la page recueil —
  résolution de zones configurables, filter_cim10_codes, classification
  DP/DR/DAS par règle métier, run_recodage_crop_pass, merge_codage_reco,
  enrich_recueil (orchestration des trois : checkboxes + ghs_injustifie
  + crop Recodage). Chaque fonction est testable indépendamment du VLM.

- pipeline/extract.py    : réduit à l'orchestration pure — ingest, routing,
  boucle page par page, délégation à recueil.enrich_recueil, validation
  ATIH finale. Plus aucune logique métier enfouie.

La fonction extract_dossier garde exactement la même signature et produit
le même JSON en sortie : aucun breaking change externe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:29:03 +02:00
Dom
1255468676 feat(ui): calibration visuelle des zones via dessin à la souris
Nouveau module pipeline/zones_config.py : charge les zones d'extraction
depuis un fichier zones_config.json (coordonnées relatives 0-1), avec
fallback sur les constantes Python. Config partagée entre :
- pipeline/extract.py (crop colonne Recodage)
- pipeline/checkboxes.py (cases Accord/Désaccord)

Zones configurables aujourd'hui (page recueil) :
- codage_reco (crop zonal pour le second passage VLM)
- accord_checkbox / desaccord_checkbox (densité de pixels)

Mode "🔧 Calibration zones" ajouté dans pipeline/ui_overlay.py :
- Sélection d'un PDF de référence (idéalement bien cadré)
- Canvas interactif (streamlit-drawable-canvas) avec les zones
  existantes pré-dessinées en rouge
- Dessin/déplacement/redimensionnement à la souris
- Saisie d'un nom et description par zone
- Sauvegarde en JSON (ou OGC_ZONES_CONFIG si défini)

Permet au métier (Khalid) de recalibrer les zones sans toucher au code,
par exemple si le formulaire ATIH évolue ou si les scans sont d'un autre
établissement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:07:59 +02:00
Dom
c0b0cd9b87 perf(ocr_qwen): support CPU + bfloat16 AVX-512 + threads explicites
Trois ajouts pour rendre le pipeline utilisable sur CPU quand la VRAM
est saturée par d'autres process :

1. Variable QWEN_DEVICE=cpu pour forcer le device CPU. Le défaut "auto"
   choisit CUDA si dispo, fallback CPU sinon.

2. Sur CPU, détection automatique du support AVX-512 BF16 via /proc/cpuinfo
   (Zen 4/5, Intel Sapphire Rapids+). Si présent, bfloat16 au lieu de
   float32 — divise par 2 la RAM et ~2x plus rapide sur matmul.

3. Appel explicite de torch.set_num_threads(N) et set_num_interop_threads(N)
   (OMP_NUM_THREADS seul ne suffit pas). Configurable via TORCH_NUM_THREADS,
   défaut = os.cpu_count().

Mesure sur Ryzen 9 9950X (Zen 5, 16c/32t, AVX-512 BF16 natif) :
- AVANT : 645% CPU (~6.5 cores), 15 Go RAM (float32)
- APRÈS : 2433% CPU (~24 cores), 8 Go RAM (bfloat16)

Appel `torch.cuda.empty_cache()` en fin d'inférence pour réduire la
fragmentation VRAM quand d'autres process GPU tournent en parallèle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:07:45 +02:00
Dom
6c8184cc03 feat(deskew): correction automatique du skew au chargement des PDFs
Nouveau module pipeline/deskew.py basé sur cv2.HoughLinesP :
- détecte les lignes quasi-horizontales (±15° de l'horizontale)
- prend la médiane de leurs angles (robuste aux outliers)
- seuils : |angle|>0.3° pour corriger, |angle|>10° = suspect (on ne corrige pas)
- PIL.rotate() avec BICUBIC + fillcolor blanc, sans expand

Intégré dans pipeline/ingest.py (paramètre `deskew=True` par défaut).
L'angle appliqué est tracé dans un fichier `page_XX.skew` à côté de
l'image, pour audit.

Mesuré sur les 18 dossiers de l'échantillon 2018 CARC : seule OGC 1 a
un skew au-dessus du seuil (+0.91°), les 17 autres sont déjà droits.
Le deskew corrige OGC 1 en 0.00° résiduel (vérif visuelle en-tête OK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:07:29 +02:00
Dom
b47f5c47e0 feat(schema): module de nettoyage des JSONs pour consommation aval
Le pipeline produit un JSON riche pendant l'exécution (ratios
checkbox, OCR raw, flags _parse_error/_truncated_loop/_crop_recodage,
_source, _elapsed_s…). Utile en audit, mais pollue quand on veut
exposer le résultat à un consommateur aval (Excel, dashboard, API).

pipeline/schema.py :
- SCHEMA_VERSION "2.0"
- clean_dossier(raw) : retourne une copie propre avec structure stable
  (en-tête → codage → GHM/GHS → décisions) et validation ATIH en
  format compact (summary + cross_checks + flags par champ).
- CLEAN_FIELDS_RECUEIL / CLEAN_FIELDS_CONCERTATION_{1,2} / CLEAN_FIELDS_PREUVES
  documentent les champs stables par type de page.
- CLI : `python -m pipeline.schema` → nettoie `output/v2/*.json` vers
  `output/v2_clean/`.

Séparation claire : `output/v2/` reste le JSON raw (audit), `output/v2_clean/`
est la sortie propre et stable pour livrables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:50 +02:00
Dom
3f2e2ee9f4 feat(extract): second passage VLM sur crop colonne Recodage (P0)
Qwen ne lit systématiquement que la colonne de gauche du tableau
Codage quand on lui donne la page recueil entière : la colonne droite
(Recodage) a 27% de couverture en V2.0 avec 100% de validité — une
régression majeure puisque c'est le cœur métier du contrôle T2A.

Solution : après le passage principal, refaire une extraction dédiée
sur un crop zonal de la seule colonne Recodage (y=0.330→0.490 pour
exclure le bloc Actes adjacent). Prompt strict anti-hallucination
("beaucoup de lignes sont vides, n'invente rien"). Le résultat écrase
partiellement `codage_reco` (DP/DR/DAS) dans le JSON principal.

Classification Python par règle métier :
- 1er code sans position  → DP
- 2e code sans position   → DR (ignoré si == DP : Qwen duplique parfois)
- codes avec position     → DAS

Filtre CIM-10 par regex en Python pour retirer les codes CCAM (actes)
qui pourraient rester si le crop déborde.

Ajout d'une env var `QWEN_MAX_PIXELS` (défaut 800) pour ajuster la
consommation VRAM sur machines avec GPU partagé (test sur RTX 5070
avec rpa_vision_v3 en parallèle).

Ajout de `torch.cuda.empty_cache()` après chaque inférence pour
réduire la fragmentation VRAM sur exécutions longues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:35 +02:00
Dom
7d45018139 feat(extract): normaliser ghs_injustifie en 0/1 (P2)
Qwen renvoie typiquement le libellé complet `0 SE 1 2 3 4 ATU FFM FSD`
dans le champ ghs_injustifie alors qu'une seule valeur 0/1 est attendue.
Ajout de `pipeline.checkboxes.parse_ghs_injustifie` qui extrait le
premier chiffre 0/1 via regex, ou "" si illisible.

Post-traitement appliqué à chaque extraction recueil et aux 18 JSONs
V2 existants (10 fichiers corrigés en place — les 8 autres avaient
déjà ghs_injustifie absent ou vide).

Note sur les 7 cases SE1-4/ATU/FFM/FSD : zones trop petites pour être
calibrées à l'œil et aucun cas positif (`ghs_injustifie=1`) dans
l'échantillon 2018 pour valider visuellement. La détection est en
placeholder, à recalibrer sur un cas positif réel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:16 +02:00
Dom
7dc3eba1fc fix(persist): corriger tag ocr_model et pipeline_version dans _meta
Auparavant le JSON de sortie étiquetait systématiquement
`ocr_model: "zai-org/GLM-OCR"` et `pipeline_version: "v1"` alors que le
pipeline avait été basculé sur Qwen2.5-VL-3B en V2.

`_meta` lit désormais `MODEL_PATH` depuis `pipeline.ocr_qwen` pour
garantir la cohérence entre le modèle effectivement utilisé et la
trace dans le fichier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:01 +02:00
47 changed files with 6663 additions and 155 deletions

View File

@@ -282,7 +282,7 @@
"ghm_reco": "06M094",
"ghs_reco": "2161",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR JP VIGNAU",
"accord_desaccord": "accord",
"_checkbox_debug": {

View File

@@ -317,7 +317,7 @@
"ghm_reco": "10M183",
"ghs_reco": "3969",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "désaccord",
"_checkbox_debug": {

View File

@@ -215,7 +215,7 @@
"ghm_reco": "06C042",
"ghs_reco": "1940",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAÚ",
"accord_desaccord": "désaccord",
"_checkbox_debug": {

View File

@@ -304,7 +304,7 @@
"ghm_reco": "01C061",
"ghs_reco": "34",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "",
"accord_desaccord": "désaccord",
"_checkbox_debug": {

View File

@@ -330,7 +330,7 @@
"ghm_reco": "03M112",
"ghs_reco": "861",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "accord",
"_checkbox_debug": {

View File

@@ -371,7 +371,7 @@
"ghm_reco": "04M093",
"ghs_reco": "1163",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "désaccord",
"_checkbox_debug": {

View File

@@ -298,7 +298,7 @@
"ghm_reco": "1947",
"ghs_reco": "06C071",
"recodage_impactant": "1",
"ghs_injustifie": "SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "accord",
"_checkbox_debug": {

View File

@@ -324,7 +324,7 @@
"ghm_reco": "23Z02Z",
"ghs_reco": "7992",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "accord",
"_checkbox_debug": {

View File

@@ -298,7 +298,7 @@
"ghm_reco": "04M092",
"ghs_reco": "1162",
"recodage_impactant": "1",
"ghs_injustifie": "0 SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "0",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "désaccord",
"_checkbox_debug": {

View File

@@ -201,7 +201,7 @@
"ghm_reco": "23Z02Z",
"ghs_reco": "7992",
"recodage_impactant": "1",
"ghs_injustifie": "SE 1 2 3 4 ATU FFM FSD",
"ghs_injustifie": "",
"praticien_conseil": "DR VIGNAU",
"accord_desaccord": "accord",
"_checkbox_debug": {

262
output/v2_clean/OGC 1.json Normal file
View File

@@ -0,0 +1,262 @@
{
"fichier": "OGC 1",
"pdf_hash": "4b2aacd453ec8903",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "1",
"n_champ": "1",
"dates_sejour": "16/02/2016 au 10/03/2016",
"sejour_etab": {
"age": "79",
"sexe": "2",
"duree_sejour": "23"
},
"sejour_reco": {
"age": "79",
"sexe": "2",
"duree_sejour": "23"
},
"rum_etab": {
"um": "53 C",
"igs": "0",
"duree": "23",
"dates": "du 16/02/2016 au 10/03/2016"
},
"codage_etab": {
"dp": "K650",
"dp_libelle": "PERITONITE AIG.",
"dr": "B966 * 4 BACILLUS FRAGILIS, CAUSE DE MAL. CLASSES DANS D'AUTRES CHAP.",
"das": []
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "06M093",
"ghs_etab": "2160",
"ghm_reco": "06M094",
"ghs_reco": "2161",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "DR JP VIGNAU",
"_validation": {
"summary": {
"valid": 6,
"invalid": 0,
"empty": 2,
"total_codes": 6,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Péritonite aigüe"
},
"dr": {
"valid": true,
"libelle_ref": "Bacillus fragilis, cause de maladies classées dans d'autres chapitres"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "2160",
"ghs_avant_concertation": "2160",
"ghs_final": "2161",
"decision": "maintien_avis_controleur",
"date_concertation": "15.3.2018",
"praticien_controleur": "DR JP VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "2160",
"valid": true
},
"ghs_avant_concertation": {
"code": "2160",
"valid": true
},
"ghs_final": {
"code": "2161",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "15.3",
"argumentaire": "102 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre IV, paragraphe 2.1 : « L'enregistrement dans le RUM d'une affection correspondant à la définition d'un DAS est obligatoire ». Au vu des éléments du dossier du patient, le codage d'un diagnostic correspondant à la définition d'un DAS a été omis par l'établissement."
},
"preuves": {
"date": "26/03/2018",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:10:02+00:00"
}
}

286
output/v2_clean/OGC 18.json Normal file
View File

@@ -0,0 +1,286 @@
{
"fichier": "OGC 18",
"pdf_hash": "0e7a44525007199c",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "18",
"n_champ": "1",
"dates_sejour": "08/03/2016 au 21/03/2016",
"sejour_etab": {
"age": "66",
"sexe": "1",
"duree_sejour": "13 8",
"mode_entree": "",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"sejour_reco": {
"age": "66",
"sexe": "1",
"duree_sejour": "13 8",
"mode_entree": "",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "13",
"dates": "du 08/03/2016 au 21/03/2016"
},
"codage_etab": {
"dp": "E43",
"dp_libelle": "MALNUTRITION PROTEINO-ENERGETIQUE GRAVE, SAI",
"dr": "J860 4 PYOTHORAX AVEC FISTULE",
"das": [
{
"code": "T858",
"position": "2",
"libelle": "COMPLIC. DE PROTH., IMPL., GREF. INT., NCA"
},
{
"code": "Z511",
"position": "3",
"libelle": "SEANCE DE CHIMIOTHERAPIE POUR TUM."
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "10M184",
"ghs_etab": "3970",
"ghm_reco": "10M183",
"ghs_reco": "3969",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 7,
"invalid": 1,
"empty": 2,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Malnutrition protéinoénergétique grave, sans précision"
},
"dr": {
"valid": false
},
"das": [
{
"valid": true,
"libelle_ref": "Autres complications de prothèses, implants et greffes internes, non classées ailleurs"
},
{
"valid": true,
"libelle_ref": "Séance de chimiothérapie pour tumeur"
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "3970",
"ghs_avant_concertation": "3969",
"ghs_final": "3969",
"decision": "",
"date_concertation": "2.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "3970",
"valid": true
},
"ghs_avant_concertation": {
"code": "3969",
"valid": true
},
"ghs_final": {
"code": "3969",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "2.3.18",
"argumentaire": "105 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre IV, paragraphe 2.1 : « Lorsqu'un patient atteint d'une maladie chronique ou de longue durée en cours de traitement est hospitalisé pour un autre motif, la maladie chronique ou de longue durée est naturellement un DAS, à moins qu'elle n'ait pas bénéficié d'une surveillance et que son traitement ait été interrompu pendant le séjour. (...) Les informations attestant de la majoration de l'effort de soins devant figurer dans le dossier médical. » Au vu des éléments du dossier du patient, la maladie chronique ou de longue durée codée en DAS par l'établissement n'a bénéficié d'aucune prise en charge diagnostique ou thérapeutique, ni majoré l'effort de prise en charge d'une autre affection."
},
"preuves": {
"date": "2018-03-13",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": true,
"photocopie": true,
"absent_date": "1ère demande",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": true,
"photocopie": true,
"absent_date": "1 à 4",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:10:41+00:00"
}
}

188
output/v2_clean/OGC 20.json Normal file
View File

@@ -0,0 +1,188 @@
{
"fichier": "OGC 20",
"pdf_hash": "eb280d07819ff75d",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "20",
"n_champ": "1",
"dates_sejour": "09/03/2016 au 18/03/2016",
"sejour_etab": {
"age": "57",
"sexe": "1",
"duree_sejour": "9",
"mode_entree": "8",
"provenance": "8",
"mode_sortie": "8",
"destination": ""
},
"sejour_reco": {
"age": "57",
"sexe": "1",
"duree_sejour": "9",
"mode_entree": "8",
"provenance": "8",
"mode_sortie": "8",
"destination": ""
},
"rum_etab": {
"um": "0",
"igs": "53 C",
"duree": "9",
"dates": "du 09/03/2016 au 18/03/2016"
},
"codage_etab": {
"dp": "D374",
"dr": "I022",
"das": [
{
"code": "T814",
"position": "3",
"libelle": "INFECT. APRES UN ACTE, NCA"
}
]
},
"codage_reco": {
"dp": "D374",
"dr": "I022",
"das": [
{
"code": "T814",
"position": "3",
"libelle": "INFECT. APRES UN ACTE, NCA"
}
]
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "06C043",
"ghs_etab": "1941",
"ghm_reco": "06C042",
"ghs_reco": "1940",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAÚ",
"_validation": {
"summary": {
"valid": 8,
"invalid": 2,
"empty": 0,
"total_codes": 10,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Côlon"
},
"dr": {
"valid": false,
"suggestion": "A022"
},
"das": [
{
"valid": true,
"libelle_ref": "Infection après un acte à visée diagnostique et thérapeutique, non classée ailleurs"
}
]
},
"codage_reco": {
"dp": {
"valid": true,
"libelle_ref": "Côlon"
},
"dr": {
"valid": false,
"suggestion": "A022"
},
"das": [
{
"valid": true,
"libelle_ref": "Infection après un acte à visée diagnostique et thérapeutique, non classée ailleurs"
}
]
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "",
"ghs_avant_concertation": "",
"ghs_final": "",
"decision": "",
"date_concertation": "2.3.18",
"praticien_controleur": "",
"medecin_dim": "",
"_validation": {
"ghs_initial": {
"code": "",
"valid": null
},
"ghs_avant_concertation": {
"code": "",
"valid": null
},
"ghs_final": {
"code": "",
"valid": null
}
}
},
"concertation_1": {
"date_concertation": "2.3.18",
"argumentaire": "Désaccord : (les éléments couverts par le secret médical sont à mentionner sur la fiche médicale de concertation)"
},
"preuves": {
"date": "",
"praticien_controleur": "",
"medecin_dim": "",
"pieces": [
{
"intitule": "Patient de 57 ans, puis en charge pour une colo-rectalgie et l'anapath. Confirme le Kc du colon.",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "En post-op, présence d'un escoulement au niveau du bas de la muqueuse ; retrait d'un aquafili et luchage.",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Il n'a pas d'abcès : une déficatga. Des JAS en T81.8. Pas de moton. Disphagie restaurée, pas de prétendu ad. de l'escoulement.",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:11:05+00:00"
}
}

273
output/v2_clean/OGC 27.json Normal file
View File

@@ -0,0 +1,273 @@
{
"fichier": "OGC 27",
"pdf_hash": "2b6010d745556c81",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "27",
"n_champ": "1",
"dates_sejour": "13/03/2016 - 16/03/2016",
"sejour_etab": {
"age": "55",
"sexe": "M",
"duree_sejour": "8"
},
"sejour_reco": {
"age": "55",
"sexe": "M",
"duree_sejour": "8"
},
"rum_etab": {
"um": "53 C",
"igs": "0",
"duree": "3",
"dates": "du 13/03/2016 au 16/03/2016"
},
"codage_etab": {
"dp": "I652",
"dp_libelle": "STENOSE DE L'ART. CAROTIDE",
"dr": "F412 2 TBL. ANXIUE ET DEPRES. MIXTE R471 2 DYSARTHRIE ET ANARTHRIE"
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [
{
"code": "EBFA012",
"position": "1",
"libelle": "THROMBOENDARTERIECTOMIE SIMPLE BIFURC. CAROTID. CERV.TOMIE"
},
{
"code": "EBFA012",
"position": "4",
"libelle": "THROMBOENDARTERIECTOMIE SIMPLE BIFURC. CAROTID. CERV.TOMIE"
}
],
"actes_reco": [],
"ghm_etab": "01C062",
"ghs_etab": "35",
"ghm_reco": "01C061",
"ghs_reco": "34",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "",
"_validation": {
"summary": {
"valid": 7,
"invalid": 1,
"empty": 2,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Occlusion et sténose de l'artère carotide"
},
"dr": {
"valid": false
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "35",
"ghs_avant_concertation": "34",
"ghs_final": "33",
"decision": "",
"date_concertation": "1.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "35",
"valid": true
},
"ghs_avant_concertation": {
"code": "34",
"valid": true
},
"ghs_final": {
"code": "33",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "13.2018",
"argumentaire": "100 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « Les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de maladies. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM10 choisi pour le DAS par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "28/07/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": [
"Dr ETTORCHI-TARDY"
],
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'inagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": true,
"photocopie": false,
"absent_date": "3-5-5",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:11:43+00:00"
}
}

143
output/v2_clean/OGC 29.json Normal file
View File

@@ -0,0 +1,143 @@
{
"fichier": "OGC 29",
"pdf_hash": "0347fc1e23968220",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "29",
"n_champ": "1",
"dates_sejour": "14/03/2016 au 17/03/2016",
"sejour_etab": {
"age": "82",
"sexe": "1",
"duree_sejour": "3"
},
"sejour_reco": {
"age": "82",
"sexe": "1",
"duree_sejour": "3"
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "3",
"dates": "du 14/03/2016 au 17/03/2016"
},
"codage_etab": {
"dp": "K635+0",
"dp_libelle": "POLYPOSE HYPERPLASIQUE",
"dr": "D509"
},
"codage_reco": {
"dp": "",
"dr": "E46",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "06M092",
"ghs_etab": "2159",
"ghm_reco": "16M112",
"ghs_reco": "6183",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 7,
"invalid": 0,
"empty": 1,
"total_codes": 7,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Polype du côlon"
},
"dr": {
"valid": true,
"libelle_ref": "Anémie par carence en fer, sans précision"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": true,
"libelle_ref": "Malnutrition protéinoénergétique, sans précision"
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "2159",
"ghs_avant_concertation": "6183",
"ghs_final": "6183",
"decision": "",
"date_concertation": "1.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "2159",
"valid": true
},
"ghs_avant_concertation": {
"code": "6183",
"valid": true
},
"ghs_final": {
"code": "6183",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "1.3.18",
"argumentaire": "109 : La facturation du GHs par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "",
"praticien_controleur": true,
"medecin_dim": true,
"pieces": [
{
"intitule": "Séjour d'hospitalisation complète",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:12:07+00:00"
}
}

174
output/v2_clean/OGC 43.json Normal file
View File

@@ -0,0 +1,174 @@
{
"fichier": "OGC 43",
"pdf_hash": "9f58db479a06194b",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "43",
"n_champ": "1",
"dates_sejour": "16/03/2016 au 22/03/2016",
"sejour_etab": {
"age": "50",
"sexe": "2",
"duree_sejour": "6",
"mode_entree": "7",
"provenance": "8",
"mode_sortie": "1",
"destination": "8"
},
"sejour_reco": {
"age": "50",
"sexe": "2",
"duree_sejour": "6",
"mode_entree": "7",
"provenance": "8",
"mode_sortie": "1",
"destination": "8"
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "6",
"dates": "du 16/03/2016 au 22/03/2016"
},
"codage_etab": {
"dp": "G409",
"dp_libelle": "EPILEP., SAI",
"dr": "K566",
"das": []
},
"codage_reco": {
"dp": "Z022",
"dr": "G409",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "01M253",
"ghs_etab": "292",
"ghm_reco": "01M32Z",
"ghs_reco": "324",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 8,
"invalid": 0,
"empty": 0,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Épilepsie, sans précision"
},
"dr": {
"valid": true,
"libelle_ref": "Occlusions intestinales, autres et sans précision"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": true,
"libelle_ref": "Examen pour l'admission dans une institution"
},
"dr": {
"valid": true,
"libelle_ref": "Épilepsie, sans précision"
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "292",
"ghs_avant_concertation": "324",
"ghs_final": "324",
"decision": "",
"date_concertation": "1.3.2018",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "292",
"valid": true
},
"ghs_avant_concertation": {
"code": "324",
"valid": true
},
"ghs_final": {
"code": "324",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "1.3.2018",
"argumentaire": "109 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "",
"praticien_controleur": "",
"medecin_dim": "",
"pieces": [
{
"intitule": "Patide de 50 ans",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Admision par : - prise en charge de crises d'épilepsie - excision p canne, dit l'épileptol.",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Syndrome acclif so costalpin quonchée - ch 1 petite ay5 en cancer du sein, dit jo chimiotherapie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Bime effectuer",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:12:34+00:00"
}
}

298
output/v2_clean/OGC 55.json Normal file
View File

@@ -0,0 +1,298 @@
{
"fichier": "OGC 55",
"pdf_hash": "948142f13e05f3bb",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "55",
"n_champ": "1",
"dates_sejour": "27/03/2016 au 01/04/2016",
"sejour_etab": {
"age": "78",
"sexe": "1",
"duree_sejour": "",
"mode_entree": "0",
"provenance": "1",
"mode_sortie": "8",
"destination": ""
},
"sejour_reco": {
"age": "78",
"sexe": "1",
"duree_sejour": "",
"mode_entree": "0",
"provenance": "1",
"mode_sortie": "8",
"destination": ""
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "5",
"dates": "du 27/03/2016 au 01/04/2016"
},
"codage_etab": {
"dp": "K123",
"dp_libelle": "MUCITE BUCCALE",
"dr": "",
"das": [
{
"code": "C795",
"position": "2",
"libelle": "T.M. IRE, DES OS ET DE LA MOELLE OSSEUSE"
},
{
"code": "D611",
"position": "4",
"libelle": "APIASIE MEDULLAIRE MEDICAM."
},
{
"code": "R630",
"position": "2",
"libelle": "ANOREXIE"
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "03M114",
"ghs_etab": "863",
"ghm_reco": "03M112",
"ghs_reco": "861",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 8,
"invalid": 0,
"empty": 3,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Mucite buccale (ulcéreuse)"
},
"dr": {
"valid": null
},
"das": [
{
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
{
"valid": true,
"libelle_ref": "Anémie médullaire [aplastique] médicamenteuse"
},
{
"valid": true,
"libelle_ref": "Anorexie"
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "863",
"ghs_avant_concertation": "861",
"ghs_final": "861",
"decision": "",
"date_concertation": "9.2.1",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "863",
"valid": true
},
"ghs_avant_concertation": {
"code": "861",
"valid": true
},
"ghs_final": {
"code": "861",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "9.3.18",
"argumentaire": "100 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « Les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) ». Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM10 choisi pour le DAS par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "26/02/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire : Biologie",
"present": true,
"photocopie": false,
"absent_date": "2 à 18",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:13:12+00:00"
}
}

333
output/v2_clean/OGC 66.json Normal file
View File

@@ -0,0 +1,333 @@
{
"fichier": "OGC 66",
"pdf_hash": "7176836d9d496cd7",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "66",
"n_champ": "1",
"dates_sejour": "04/04/2016 au 20/04/2016",
"sejour_etab": {
"age": "80 ans",
"sexe": "1",
"duree_sejour": "16 jours",
"mode_entree": "8",
"provenance": "8",
"mode_sortie": "8",
"destination": "0"
},
"sejour_reco": {
"age": "80 ans",
"sexe": "1",
"duree_sejour": "16 jours",
"mode_entree": "8",
"provenance": "8",
"mode_sortie": "8",
"destination": "0"
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "16 jours",
"dates": "du 04/04/2016 au 20/04/2016"
},
"codage_etab": {
"dp": "C07",
"dp_libelle": "T.M. DE LA GLD, PAROTIDE",
"dr": "C795 *",
"das": [
{
"code": "E440",
"position": "2",
"libelle": "MALNUTRITION PROTEINO-ENERGETIQUE MODEREE"
},
{
"code": "J91",
"position": "2",
"libelle": "EPANCHEMENT PLEURAL AVEC MAL. CL. AILL."
},
{
"code": "Z511",
"position": "3",
"libelle": "SEANCE DE CHIMIOTHERAPIE POUR TUM."
}
]
},
"codage_reco": {
"dp": "C07",
"dr": "C795 *",
"das": [
{
"code": "E440",
"position": "2",
"libelle": "MALNUTRITION PROTEINO-ENERGETIQUE MODEREE"
},
{
"code": "J91",
"position": "2",
"libelle": "EPANCHEMENT PLEURAL AVEC MAL. CL. AILL."
},
{
"code": "Z511",
"position": "3",
"libelle": "SEANCE DE CHIMIOTHERAPIE POUR TUM."
}
]
},
"actes_etab": [
{
"code": "EBLA003",
"position": "1",
"libelle": "POSE CATHE RELIE - 1VN PROF. MB SUP/COU TRANSCUT+DIFFUSEUR SSCUT."
}
],
"actes_reco": [],
"ghm_etab": "03M073",
"ghs_etab": "844",
"ghm_reco": "04M093",
"ghs_reco": "1163",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 15,
"invalid": 0,
"empty": 0,
"total_codes": 15,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Tumeur maligne de la glande parotide"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
"das": [
{
"valid": true,
"libelle_ref": "Malnutrition protéinoénergétique modérée"
},
{
"valid": true,
"libelle_ref": "Épanchement pleural au cours de maladies classées ailleurs"
},
{
"valid": true,
"libelle_ref": "Séance de chimiothérapie pour tumeur"
}
]
},
"codage_reco": {
"dp": {
"valid": true,
"libelle_ref": "Tumeur maligne de la glande parotide"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
"das": [
{
"valid": true,
"libelle_ref": "Malnutrition protéinoénergétique modérée"
},
{
"valid": true,
"libelle_ref": "Épanchement pleural au cours de maladies classées ailleurs"
},
{
"valid": true,
"libelle_ref": "Séance de chimiothérapie pour tumeur"
}
]
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "844",
"ghs_avant_concertation": "1163",
"ghs_final": "163",
"decision": "",
"date_concertation": "2.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "844",
"valid": true
},
"ghs_avant_concertation": {
"code": "1163",
"valid": true
},
"ghs_final": {
"code": "163",
"valid": false
}
}
},
"concertation_1": {
"date_concertation": "2.3.19",
"argumentaire": "La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "28/07/2019",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr ETTORCHI-TARDY"
],
"medecin_dim": "",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre : lettre M",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:13:54+00:00"
}
}

298
output/v2_clean/OGC 68.json Normal file
View File

@@ -0,0 +1,298 @@
{
"fichier": "OGC 68",
"pdf_hash": "53423419517fca40",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "68",
"n_champ": "1",
"dates_sejour": "07/04/2016 au 11/04/2016",
"sejour_etab": {
"age": "63",
"sexe": "1",
"duree_sejour": "4",
"mode_entree": "7",
"provenance": "1",
"mode_sortie": "7",
"destination": "1"
},
"sejour_reco": {
"age": "63",
"sexe": "1",
"duree_sejour": "4",
"mode_entree": "7",
"provenance": "1",
"mode_sortie": "7",
"destination": "1"
},
"rum_etab": {
"um": "40 C",
"igs": "0",
"duree": "4",
"dates": "du 07/04/2016 au 11/04/2016"
},
"codage_etab": {
"dp": "R650",
"dp_libelle": "SYND. REPONSE INFLAM. SYST. ORIGINE INFECT. SANS DEFAILLANCE ORG",
"dr": "",
"das": [
{
"code": "D508",
"position": "2",
"libelle": "ANEMIES PAR CARENCHE EN FER, NCA"
},
{
"code": "E8758",
"position": "2",
"libelle": "HYPERKALIEMIES, NCA AT SAI"
},
{
"code": "K868",
"position": "2",
"libelle": "MAL. PREC. DU PANCREAS, NCA"
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "18M042",
"ghs_etab": "6773",
"ghm_reco": "07M112",
"ghs_reco": "2550",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 8,
"invalid": 0,
"empty": 3,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Syndrome de réponse inflammatoire systémique d'origine infectieuse sans défaillance d'organe"
},
"dr": {
"valid": null
},
"das": [
{
"valid": true,
"libelle_ref": "Autres anémies par carence en fer"
},
{
"valid": true,
"libelle_ref": "Hyperkaliémies, autres et sans précision"
},
{
"valid": true,
"libelle_ref": "Autres maladies précisées du pancréas"
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "6773",
"ghs_avant_concertation": "6973-3",
"ghs_final": "2550",
"decision": "maintien_avis_controleur",
"date_concertation": "16.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "6773",
"valid": true
},
"ghs_avant_concertation": {
"code": "6973-3",
"valid": false
},
"ghs_final": {
"code": "2550",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "16.3.18",
"argumentaire": "109 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "28/06/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:14:34+00:00"
}
}

270
output/v2_clean/OGC 69.json Normal file
View File

@@ -0,0 +1,270 @@
{
"fichier": "OGC 69",
"pdf_hash": "75cf0c18f2090864",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "69",
"n_champ": "1",
"dates_sejour": "10/04/2016 au 13/04/2016",
"sejour_etab": {
"age": "87",
"sexe": "2",
"duree_sejour": "3",
"mode_entree": "8",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"sejour_reco": {
"age": "87",
"sexe": "2",
"duree_sejour": "3",
"mode_entree": "8",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "3",
"dates": "du 10/04/2016 au 13/04/2016"
},
"codage_etab": {
"dp": "K623",
"dp_libelle": "PROPLAPUS RECTAL",
"dr": "G20",
"das": []
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "1948",
"ghs_etab": "06C072",
"ghm_reco": "1947",
"ghs_reco": "06C071",
"recodage_impactant": "1",
"ghs_injustifie": "",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 2,
"invalid": 4,
"empty": 2,
"total_codes": 6,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Prolapsus rectal"
},
"dr": {
"valid": true,
"libelle_ref": "Maladie de Parkinson"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": false
},
"ghs_etab": {
"valid": false
},
"ghm_reco": {
"valid": false
},
"ghs_reco": {
"valid": false
},
"cross_checks": {
"etab_ghm_ghs_coherent": null,
"reco_ghm_ghs_coherent": null
}
}
},
"concertation_2": {
"ghs_initial": "1948",
"ghs_avant_concertation": "1947",
"ghs_final": "1947",
"decision": "",
"date_concertation": "13-02",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "1948",
"valid": true
},
"ghs_avant_concertation": {
"code": "1947",
"valid": true
},
"ghs_final": {
"code": "1947",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "1 - 3 - 18",
"argumentaire": "104 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre IV, paragraphe 2.1 : « Un diagnostic associé est significatif s'il est pris en charge à titre diagnostique ou thérapeutique ou s'il majoré l'effort de prise en charge d'une autre affection. Par prise en charge diagnostique on entend la mise en œuvre de moyens nécessaires au diagnostic d'une affection nouvelle ou au « bilan » d'une affection préexistante. Par prise en charge thérapeutique on entend la réalisation d'un traitement. Par majoration de l'effort de prise en charge d'une autre affection on entend l'augmentation imposée par une affection B de l'effort de soins relatif à une affection A enregistrée comme diagnostic principal (DP), diagnostic relié (DR) ou DAS, par rapport à ce qu'il aurait dû être en l'absence de B. Si l'affection B, quoique non prise en charge à titre diagnostique ou thérapeutique, a néanmoins alourdi la prise en charge de A, alors B est un DAS. (...) Ne doivent pas être retenues comme significatives les affections ne respectant pas la définition, par exemple, les antécédents guéris, les maladies stabilisées ou les facteurs de risque n'ayant bénéficié d'aucune prise en charge ». Au vu des éléments du dossier du patient, le DAS choisi par l'établissement ne peut pas être codé, ce diagnostic associé n'ayant nécessité aucune prise en charge documentée au dossier."
},
"preuves": {
"date": "2017",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:15:13+00:00"
}
}

263
output/v2_clean/OGC 7.json Normal file
View File

@@ -0,0 +1,263 @@
{
"fichier": "OGC 7",
"pdf_hash": "e8dc0ec994333b18",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "7",
"n_champ": "1",
"dates_sejour": "01/03/2016 au 04/03/2016",
"sejour_etab": {
"age": "75",
"sexe": "1",
"duree_sejour": "3"
},
"sejour_reco": {
"age": "75",
"sexe": "1",
"duree_sejour": "3"
},
"rum_etab": {
"um": "40 C",
"igs": "0",
"duree": "3",
"dates": "du 01/03/2016 au 04/03/2016"
},
"codage_etab": {
"dp": "TS10",
"dp_libelle": "HEMORR. ET HEMATOME COMPLIQ. UN ACTE, NCA",
"dr": "R33",
"das": []
},
"codage_reco": {
"dp": "",
"dr": "R33",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "21M162",
"ghs_etab": "7610",
"ghm_reco": "11M122",
"ghs_reco": "4323",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "",
"_validation": {
"summary": {
"valid": 6,
"invalid": 1,
"empty": 1,
"total_codes": 7,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": false,
"suggestion": "T010"
},
"dr": {
"valid": true,
"libelle_ref": "Rétention d'urine"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": true,
"libelle_ref": "Rétention d'urine"
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "7610",
"ghs_avant_concertation": "4323",
"ghs_final": "4323",
"decision": "",
"date_concertation": "1.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "7610",
"valid": true
},
"ghs_avant_concertation": {
"code": "4323",
"valid": true
},
"ghs_final": {
"code": "4323",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "1.3",
"argumentaire": "109 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de maladies. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "26/04/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:15:44+00:00"
}
}

272
output/v2_clean/OGC 74.json Normal file
View File

@@ -0,0 +1,272 @@
{
"fichier": "OGC 74",
"pdf_hash": "58076293464e9771",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "74",
"n_champ": "1",
"dates_sejour": "08/04/2016 au 13/04/2016",
"sejour_etab": {
"age": "52",
"sexe": "1",
"duree_sejour": "5",
"mode_entree": "1",
"provenance": "8",
"mode_sortie": "1",
"destination": "8"
},
"sejour_reco": {
"age": "52",
"sexe": "1",
"duree_sejour": "5",
"mode_entree": "1",
"provenance": "8",
"mode_sortie": "1",
"destination": "8"
},
"rum_etab": {
"um": "40 C",
"igs": "II",
"duree": "5",
"dates": "du 08/04/2016 au 13/04/2016"
},
"codage_etab": {
"dp": "A099",
"dp_libelle": "GASTROENTERITE COLITE ORIGINE SAI",
"dr": "C795 * 2",
"das": []
},
"codage_reco": {
"dp": "A099",
"dr": "C795 * 2",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "06M032",
"ghs_etab": "2130",
"ghm_reco": "18M041",
"ghs_reco": "6772",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR JP VIGNAU",
"_validation": {
"summary": {
"valid": 8,
"invalid": 0,
"empty": 0,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Gastroentérite et colite dorigine non précisée"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": true,
"libelle_ref": "Gastroentérite et colite dorigine non précisée"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "",
"ghs_avant_concertation": "",
"ghs_final": "",
"decision": "",
"date_concertation": "2-3",
"praticien_controleur": "",
"medecin_dim": "",
"_validation": {
"ghs_initial": {
"code": "",
"valid": null
},
"ghs_avant_concertation": {
"code": "",
"valid": null
},
"ghs_final": {
"code": "",
"valid": null
}
}
},
"concertation_1": {
"date_concertation": "2.3.18",
"argumentaire": "Atteste avoir pris connaissance des éléments du dossier y compris ceux couverts par le secret médical et des arguments soutenus par les médecins contrôleurs et avoir eu l'opportunité d'en débattre contradictoirement"
},
"preuves": {
"date": "2023/01/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte : TDP TAP TRN",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire :",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie :",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre : b-c",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:16:17+00:00"
}
}

262
output/v2_clean/OGC 76.json Normal file
View File

@@ -0,0 +1,262 @@
{
"fichier": "OGC 76",
"pdf_hash": "96f8ce5a1672aad9",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "76",
"n_champ": "1",
"dates_sejour": "05/04/2016 au 11/04/2016",
"sejour_etab": {
"age": "54",
"sexe": "2",
"duree_sejour": "6"
},
"sejour_reco": {
"age": "54",
"sexe": "2",
"duree_sejour": "6"
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "6",
"dates": "du 05/04/2016 au 11/04/2016"
},
"codage_etab": {
"dp": "F329",
"dp_libelle": "EPISODE DEPRES., SAI",
"dr": "Z511",
"das": []
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "19M113",
"ghs_etab": "7086",
"ghm_reco": "17M061",
"ghs_reco": "6487",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 6,
"invalid": 0,
"empty": 2,
"total_codes": 6,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Épisode dépressif, sans précision"
},
"dr": {
"valid": true,
"libelle_ref": "Séance de chimiothérapie pour tumeur"
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "7086",
"ghs_avant_concertation": "6487",
"ghs_final": "6487",
"decision": "maintien_avis_controleur",
"date_concertation": "1.3.19",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "7086",
"valid": true
},
"ghs_avant_concertation": {
"code": "6487",
"valid": true
},
"ghs_final": {
"code": "6487",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "13.2018",
"argumentaire": "124: La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. En préalable, chapitre VI, paragraphe 1.2 : « Les circonstances du diagnostic préalable n'importent pas (...) La situation de traitement est présente lorsque le diagnostic de l'affection est fait au moment de l'entrée du patient dans l'unité médicale et que l'admission a pour but le traitement de l'affection. » Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre VI, paragraphe 1.2.1 : « La dénomination traitement répétitif rassemble les traitements qui, par nature, imposent une administration répétitive. (...) Dans les situations de traitement répétitif le codage du DP utilise des codes du chapitre XXI de la CIM-10 (« codes Z »). [Règle T1]. La règle est la même si la prise en charge, incidentellement, n'a lieu qu'une fois : c'est la nature du traitement qui est prise en considération. (...) Les séjours pour chimiothérapie, radiothérapie, transfusion sanguine, apheresis sanguine, oxygénothérapie hyperbare, injection de fer (pour carence martiale) qu'il s'agisse de séances ou d'hospitalisation complète, doivent avoir en position de DP le code adéquat de la catégorie Z51 de la CIM10. » Au vu des éléments présents dans le dossier du patient, alors que l'affection a été motivée par un traitement correspondant à la définition de traitement répétitif, l'établissement n'a pas retenu en DP le code du chapitre XXI de la CIM-10 en Z imposé par l'annexe II."
},
"preuves": {
"date": "27/04/2018",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": true,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre : CR psychologue",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:16:57+00:00"
}
}

300
output/v2_clean/OGC 84.json Normal file
View File

@@ -0,0 +1,300 @@
{
"fichier": "OGC 84",
"pdf_hash": "0f61532689f119c5",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "84",
"n_champ": "1",
"dates_sejour": "07/04/2016 au 16/04/2016",
"sejour_etab": {
"age": "47",
"sexe": "1",
"duree_sejour": "9"
},
"sejour_reco": {
"age": "47",
"sexe": "1",
"duree_sejour": "9"
},
"rum_etab": {
"um": "0",
"igs": "0",
"duree": "9",
"dates": "du 07/04/2016 au 16/04/2016"
},
"codage_etab": {
"dp": "C257",
"dp_libelle": "T.M. D'AUTRES PARTIES DU PANCREAS",
"dr": "C787 *",
"das": [
{
"code": "N179",
"position": "2",
"libelle": "T.M. IRE. DU FOIE ET V.B. INTRAHEP."
},
{
"code": "R18",
"position": "2",
"libelle": "ASCITE"
},
{
"code": "R410",
"position": "2",
"libelle": "DESORIENTATION, SAI"
},
{
"code": "Z515",
"position": "3",
"libelle": "SOINS PALLIATIFS"
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "07M063",
"ghs_etab": "2526",
"ghm_reco": "23Z02Z",
"ghs_reco": "7992",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 10,
"invalid": 0,
"empty": 2,
"total_codes": 10,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Autres parties du pancréas"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire du foie et des voies biliaires intrahépatiques"
},
"das": [
{
"valid": true,
"libelle_ref": "Insuffisance rénale aigüe, sans précision"
},
{
"valid": true,
"libelle_ref": "Ascite"
},
{
"valid": true,
"libelle_ref": "Désorientation, sans précision"
},
{
"valid": true,
"libelle_ref": "Soins palliatifs"
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "2526",
"ghs_avant_concertation": "7992",
"ghs_final": "7992",
"decision": "maintien_avis_controleur",
"date_concertation": "9.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "2526",
"valid": true
},
"ghs_avant_concertation": {
"code": "7992",
"valid": true
},
"ghs_final": {
"code": "7992",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "9.3.18",
"argumentaire": "136 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre VI, paragraphe 1.2.2.3, dans certaines situations de traitement unique médical, en exception à la règle générale, le DP est imposé [Règle T11]. A noter que « la maladie traitée est enregistrée comme diagnostic relié chaque fois qu'elle respecte sa définition. » Au vu des éléments présents dans le dossier du patient, alors que l'admission a été motivée pour une situation de traitement unique médical telle que décrite dans le chapitre VI, paragraphe 1.2.2.3 de l'annexe II dont le code CIM-10 est imposé, l'établissement n'a pas appliqué la règle T11 pour le codage du DP."
},
"preuves": {
"date": "01/03/18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:17:36+00:00"
}
}

269
output/v2_clean/OGC 86.json Normal file
View File

@@ -0,0 +1,269 @@
{
"fichier": "OGC 86",
"pdf_hash": "ff2bb027e50bd9f6",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "86",
"n_champ": "1",
"dates_sejour": "14/04/2016 au 20/04/2016",
"sejour_etab": {
"age": "75",
"sexe": "1",
"duree_sejour": "",
"mode_entree": "1",
"provenance": "",
"mode_sortie": "9",
"destination": ""
},
"sejour_reco": {
"age": "75",
"sexe": "1",
"duree_sejour": "",
"mode_entree": "1",
"provenance": "",
"mode_sortie": "9",
"destination": ""
},
"rum_etab": {
"um": "",
"igs": "",
"duree": "",
"dates": ""
},
"codage_etab": {
"dp": "C349",
"dp_libelle": "T.M. DE BRONCHE OU DU POUMON, SAI",
"dr": "",
"das": []
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "04M093",
"ghs_etab": "1163",
"ghm_reco": "04M092",
"ghs_reco": "1162",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 5,
"invalid": 0,
"empty": 3,
"total_codes": 5,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Bronche ou poumon, sans précision"
},
"dr": {
"valid": null
},
"das": []
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "1163",
"ghs_avant_concertation": "1162",
"ghs_final": "A-162",
"decision": "",
"date_concertation": "2.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "1163",
"valid": true
},
"ghs_avant_concertation": {
"code": "1162",
"valid": true
},
"ghs_final": {
"code": "A-162",
"valid": false
}
}
},
"concertation_1": {
"date_concertation": "2.3",
"argumentaire": "105 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur un diagnostic associé significatif (DAS) codé par l'établissement dans le résumé d'unité médicale (RUM). Ce DAS n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre IV, paragraphe 2.1 : « Lorsqu'un patient atteint d'une maladie chronique ou de longue durée est naturellement un DAS, à moins qu'elle n'ait pas bénéficié d'une surveillance et que son traitement ait été interrompu pendant le séjour. (...) Les informations attestant de la majoration de l'effort de soins devant figurer dans le dossier médical. » Au vu des éléments du dossier du patient, la maladie chronique ou de longue durée codée en DAS par l'établissement n'a bénéficié d'aucune prise en charge diagnostique ou thérapeutique, ni majoré l'effort de prise en charge d'une autre affection."
},
"preuves": {
"date": "28/12/14",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU",
"Dr PROMAX"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Observations médicales",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:18:12+00:00"
}
}

176
output/v2_clean/OGC 9.json Normal file
View File

@@ -0,0 +1,176 @@
{
"fichier": "OGC 9",
"pdf_hash": "eaa3cb08305ad415",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "9",
"n_champ": "1",
"dates_sejour": "01/03/2016 au 08/03/2016",
"sejour_etab": {
"age": "84",
"sexe": "1",
"duree_sejour": "7",
"mode_entree": "8",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"sejour_reco": {
"age": "84",
"sexe": "1",
"duree_sejour": "7",
"mode_entree": "8",
"provenance": "",
"mode_sortie": "8",
"destination": ""
},
"rum_etab": {
"um": "53 C",
"igs": "0",
"duree": "7",
"dates": "du 01/03/2016 au 08/03/2016"
},
"codage_etab": {
"dp": "C61",
"dp_libelle": "T.M. DE LA PROSTATE",
"dr": "",
"das": [
{
"code": "",
"position": "",
"libelle": ""
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": [
{
"code": "",
"position": ""
}
]
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "12C042",
"ghs_etab": "4519",
"ghm_reco": "11C132",
"ghs_reco": "4169",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 5,
"invalid": 0,
"empty": 5,
"total_codes": 5,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Tumeur maligne de la prostate"
},
"dr": {
"valid": null
},
"das": [
{
"valid": null
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": [
{
"valid": null
}
]
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "4519",
"ghs_avant_concertation": "4169",
"ghs_final": "4169",
"decision": "",
"date_concertation": "13.8",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "4519",
"valid": true
},
"ghs_avant_concertation": {
"code": "4169",
"valid": true
},
"ghs_final": {
"code": "4169",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "1.3.18",
"argumentaire": "109 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "",
"praticien_controleur": "",
"medecin_dim": "",
"pieces": [
{
"intitule": "Commentaires du médecin contrôleur",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Commentaires du médecin du DIM",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:18:38+00:00"
}
}

171
output/v2_clean/OGC 97.json Normal file
View File

@@ -0,0 +1,171 @@
{
"fichier": "OGC 97",
"pdf_hash": "a3d49b8a6bdef1b8",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "97",
"n_champ": "1",
"dates_sejour": "20/04/2016 au 29/04/2016",
"sejour_etab": {
"age": "66",
"sexe": "1",
"duree_sejour": "9 8",
"mode_entree": "",
"provenance": "9",
"mode_sortie": "9",
"destination": "1"
},
"sejour_reco": {
"age": "66",
"sexe": "1",
"duree_sejour": "9 8",
"mode_entree": "",
"provenance": "9",
"mode_sortie": "9",
"destination": "1"
},
"rum_etab": {
"um": "0",
"igs": "0",
"duree": "9",
"dates": "du 20/04/2016 au 29/04/2016"
},
"codage_etab": {
"dp": "C186",
"dp_libelle": "T.M. DU COLON DESCENDANT",
"dr": "C787 * 2 T.M. IIRE. DU FOIE ET V.B. INTRAHEP.",
"das": [
{
"code": "R18",
"position": "2",
"libelle": "ASCITE"
},
{
"code": "Z515",
"position": "3",
"libelle": "SOINS PALLIATIFS"
}
]
},
"codage_reco": {
"dp": "",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "06M053",
"ghs_etab": "2140",
"ghm_reco": "23Z02Z",
"ghs_reco": "7992",
"recodage_impactant": "1",
"ghs_injustifie": "",
"accord_desaccord": "accord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 8,
"invalid": 0,
"empty": 2,
"total_codes": 8,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Côlon descendant"
},
"dr": {
"valid": true,
"libelle_ref": "Tumeur maligne secondaire du foie et des voies biliaires intrahépatiques"
},
"das": [
{
"valid": true,
"libelle_ref": "Ascite"
},
{
"valid": true,
"libelle_ref": "Soins palliatifs"
}
]
},
"codage_reco": {
"dp": {
"valid": null
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "2140",
"ghs_avant_concertation": "7992",
"ghs_final": "7942",
"decision": "",
"date_concertation": "2.3.18",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "2140",
"valid": true
},
"ghs_avant_concertation": {
"code": "7992",
"valid": true
},
"ghs_final": {
"code": "7942",
"valid": false
}
}
},
"concertation_1": {
"date_concertation": "",
"argumentaire": "136 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre VI, paragraphe 1.2.2.3, dans certaines situations de traitement unique médical, en exception à la règle générale, le DP est imposé (règle T11). A noter que « la maladie traitée est enregistrée comme diagnostic relié chaque fois qu'elle respecte sa définition. » Au vu des éléments présents dans le dossier du patient, alors que l'admission a été motivée pour une situation de traitement unique médical telle que décrite dans le chapitre VI, paragraphe 1.2.2.3 de l'annexe II dont le code CIM-10 est imposé, l'établissement n'a pas appliqué la règle T11 pour le codage du DP."
},
"preuves": {
"date": "29-avril",
"praticien_controleur": "Pachis de 66 ans",
"medecin_dim": "Admis par AEB, dcer, acité, guère en stage palliatif. Très : cancer céleste mal: métastatique traité depuis 3ans. Perte en charge: - ponction d'acite - pas en stage de la douleur, - sth doligiques et cédation. Dès le 29-avril.",
"pieces": [
{
"intitule": "État DP",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:19:05+00:00"
}
}

372
output/v2_clean/OGC 99.json Normal file
View File

@@ -0,0 +1,372 @@
{
"fichier": "OGC 99",
"pdf_hash": "078ba737e95e7659",
"schema_version": "2.0",
"extraction": {
"recueil": {
"etablissement": "CLINIQUE D'ARCACHON",
"finess": "330780206",
"date_debut_controle": "13/02/2018",
"n_ogc": "99",
"n_champ": "1",
"dates_sejour": "16/04/2016 au 06/05/2016",
"sejour_etab": {
"age": "66",
"sexe": "1",
"duree_sejour": "20"
},
"sejour_reco": {
"age": "66",
"sexe": "1",
"duree_sejour": "20"
},
"rum_etab": {
"um": "UM",
"igs": "IGS II",
"duree": "20",
"dates": "du 16/04/2016 au 06/05/2016"
},
"codage_etab": {
"dp": "T827",
"dp_libelle": "INFECT. ET REAC. INFL. DUES A PROTH.",
"dr": "B957 *",
"das": [
{
"code": "B957",
"position": "2",
"libelle": "STAPHYLO. NICA, CAUSE DE MAL. CLASSES DANS D'AUTRES CHAP."
},
{
"code": "B957",
"position": "2",
"libelle": "STAPHYLO. NICA, CAUSE DE MAL. CLASSES DANS D'AUTRES CHAP."
},
{
"code": "C795",
"position": "2",
"libelle": "PSEUDOMONAS, CAUSE DE MAL. CLASSES DANS D'AUTRES CHAP."
},
{
"code": "C795",
"position": "2",
"libelle": "PSEUDOMONAS, CAUSE DE MAL. CLASSES DANS D'AUTRES CHAP."
},
{
"code": "C797",
"position": "2",
"libelle": "T.M. IRE. DES OS ET DE LA MOELLE OSSEUSE"
},
{
"code": "C797",
"position": "2",
"libelle": "T.M. IRE. DES OS ET DE LA MOELLE OSSEUSE"
},
{
"code": "D619",
"position": "2",
"libelle": "APLAISIE MEDULLAIRE, SAI"
},
{
"code": "D619",
"position": "2",
"libelle": "APLAISIE MEDULLAIRE, SAI"
},
{
"code": "R650",
"position": "2",
"libelle": "ANEMIE AVEC MAL. TUMORALES"
},
{
"code": "R650",
"position": "2",
"libelle": "ANEMIE AVEC MAL. TUMORALES"
},
{
"code": "Z290",
"position": "2",
"libelle": "ISOLEMENT"
},
{
"code": "Z290",
"position": "2",
"libelle": "ISOLEMENT"
}
]
},
"codage_reco": {
"dp": "A415",
"dr": "",
"das": []
},
"actes_etab": [],
"actes_reco": [],
"ghm_etab": "21M164",
"ghs_etab": "7612",
"ghm_reco": "18M073",
"ghs_reco": "6783",
"recodage_impactant": "1",
"ghs_injustifie": "0",
"accord_desaccord": "désaccord",
"praticien_conseil": "DR VIGNAU",
"_validation": {
"summary": {
"valid": 19,
"invalid": 0,
"empty": 1,
"total_codes": 19,
"ghm_ghs_incoherents": 0
},
"codage_etab": {
"dp": {
"valid": true,
"libelle_ref": "Infection et réaction inflammatoire dues à d'autres prothèses, implants et greffes cardiaques et vasculaires"
},
"dr": {
"valid": true,
"libelle_ref": "Autres staphylocoques, cause de maladies classées dans d'autres chapitres"
},
"das": [
{
"valid": true,
"libelle_ref": "Autres staphylocoques, cause de maladies classées dans d'autres chapitres"
},
{
"valid": true,
"libelle_ref": "Autres staphylocoques, cause de maladies classées dans d'autres chapitres"
},
{
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
{
"valid": true,
"libelle_ref": "Tumeur maligne secondaire des os et de la moelle osseuse"
},
{
"valid": true,
"libelle_ref": "Tumeur maligne secondaire de la glande surrénale"
},
{
"valid": true,
"libelle_ref": "Tumeur maligne secondaire de la glande surrénale"
},
{
"valid": true,
"libelle_ref": "Anémie médullaire [aplastique], sans précision"
},
{
"valid": true,
"libelle_ref": "Anémie médullaire [aplastique], sans précision"
},
{
"valid": true,
"libelle_ref": "Syndrome de réponse inflammatoire systémique d'origine infectieuse sans défaillance d'organe"
},
{
"valid": true,
"libelle_ref": "Syndrome de réponse inflammatoire systémique d'origine infectieuse sans défaillance d'organe"
},
{
"valid": true,
"libelle_ref": "Isolement"
},
{
"valid": true,
"libelle_ref": "Isolement"
}
]
},
"codage_reco": {
"dp": {
"valid": true,
"libelle_ref": "Sepsis à d'autres microorganismes Gram négatif"
},
"dr": {
"valid": null
},
"das": []
},
"ghm_etab": {
"valid": true
},
"ghs_etab": {
"valid": true
},
"ghm_reco": {
"valid": true
},
"ghs_reco": {
"valid": true
},
"cross_checks": {
"etab_ghm_ghs_coherent": true,
"reco_ghm_ghs_coherent": true
}
}
},
"concertation_2": {
"ghs_initial": "7612",
"ghs_avant_concertation": "6783",
"ghs_final": "6783",
"decision": "maintien_avis_controleur",
"date_concertation": "2.3.19",
"praticien_controleur": "DR VIGNAU",
"medecin_dim": "DR ETTORCHI-TARDY",
"_validation": {
"ghs_initial": {
"code": "7612",
"valid": true
},
"ghs_avant_concertation": {
"code": "6783",
"valid": true
},
"ghs_final": {
"code": "6783",
"valid": true
}
}
},
"concertation_1": {
"date_concertation": "2.3",
"argumentaire": "109 : La facturation du GHS par l'établissement n'est pas conforme à l'article 1 de l'arrêté du 19 février 2015 modifié du fait d'un non-respect des règles de codage édictées dans l'annexe II de l'arrêté du 21 décembre 2015 modifiant l'arrêté du 22 février 2008. Le non-respect des règles porte sur le diagnostic principal (DP) codé par l'établissement dans le résumé d'unité médicale (RUM). Le DP n'est pas conforme aux règles de codage des diagnostics rappelées par l'annexe II, chapitre V, paragraphe 1 : « les diagnostics doivent figurer dans le RUM sous forme codée selon la plus récente mise à jour de la 10e révision de la Classification internationale des maladies (CIM-10) de l'Organisation mondiale de la santé et selon les extensions nationales données dans la plus récente version du Manuel des groupes homogènes de malades. (...) Le meilleur code est le plus précis par rapport à l'information à coder. » Au vu des éléments présents dans le dossier du patient, le code CIM-10 choisi pour le DP par l'établissement n'est pas le plus précis par rapport à l'information à coder."
},
"preuves": {
"date": "27.2.18",
"praticien_controleur": [
"Dr RADZIKOWSKI",
"Dr DELAYE-PHULPIN",
"Dr TURBAN",
"Dr DUVAL",
"Dr VIGNAU"
],
"medecin_dim": "Dr ETTORCHI-TARDY",
"pieces": [
{
"intitule": "Compte-rendu d'acte",
"present": true,
"photocopie": false,
"absent_date": "3 à 6",
"date_obtention": ""
},
{
"intitule": "Compte-rendu opératoire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'accouchement",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'examen complémentaire",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'imagerie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'anatomopathologie",
"present": false,
"photocopie": false,
"absent_date": "1er demande",
"date_obtention": "16"
},
{
"intitule": "Observations médicales",
"present": true,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier de transfusion",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Dossier d'anesthésie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Administration thérapeutique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Compte-rendu d'hospitalisation",
"present": true,
"photocopie": false,
"absent_date": "1-2",
"date_obtention": ""
},
{
"intitule": "Lettre de sortie",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Eléments de surveillance du dossier infirmier",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge psychologue",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge kinésithérapeute",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Prise en charge diététique",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
},
{
"intitule": "Autre",
"present": false,
"photocopie": false,
"absent_date": "",
"date_obtention": ""
}
]
}
},
"_meta": {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": "2026-04-24T12:19:48+00:00"
}
}

View File

@@ -39,6 +39,14 @@ CONCERTATION_2_DECISION = CheckboxZones(
desaccord= (0.280, 0.270, 0.305, 0.290), # retour groupage DIM
)
# Zones des 7 cases SE 1 / 2 / 3 / 4 / ATU / FFM / FSD (page recueil, en bas).
# TODO : recalibrer avec des vrais cas positifs — sur 18 dossiers de
# l'échantillon 2018, aucune case n'est cochée (`ghs_injustifie = 0` partout)
# donc impossible de valider visuellement la détection. Laissé désactivé.
GHS_INJUSTIFIE_CHECKBOXES: dict[str, tuple[float, float, float, float]] = {
# placeholder — à recalibrer quand un cas positif sera observé
}
def dark_ratio(image: Image.Image, zone: tuple[float, float, float, float],
inner_frac: float = INNER_FRAC) -> float:
@@ -58,6 +66,31 @@ def dark_ratio(image: Image.Image, zone: tuple[float, float, float, float],
return float(np.mean(gray < DARK_THRESHOLD))
def parse_ghs_injustifie(raw: str) -> str:
"""Extrait la valeur 0/1 du champ ghs_injustifie depuis la sortie OCR brute.
Qwen tend à recopier le libellé complet `0 SE 1 2 3 4 ATU FFM FSD` au lieu
du seul chiffre. On prend le premier caractère qui est 0 ou 1 et on ignore
le reste (les chiffres 1/2/3/4 qui suivent « SE » sont des numéros de case,
pas la valeur du flag).
"""
if raw is None:
return ""
s = str(raw).strip()
if not s:
return ""
# Si déjà propre (juste "0" ou "1"), retour direct
if s in ("0", "1"):
return s
# Prendre le premier chiffre trouvé qui soit 0 ou 1, en ignorant tout
# le reste (en particulier les "SE 1 2 3 4…" qui suivent)
import re as _re
m = _re.match(r"\s*([01])\b", s)
if m:
return m.group(1)
return "" # illisible / format inattendu
def detect_accord_desaccord(
image_path: str | Path,
zones: CheckboxZones = RECUEIL_ACCORD_DESACCORD,

125
pipeline/deskew.py Normal file
View File

@@ -0,0 +1,125 @@
"""Détection d'angle de skew + redressement automatique des pages scannées.
Technique : Hough Transform sur les lignes détectées par Canny, puis moyenne
des angles des lignes « quasi horizontales » (±15° par rapport à l'horizontale).
Les fiches OGC ont énormément de traits de tableau → signal très fort.
Seuil : on ne corrige que si |angle| > `MIN_ANGLE_DEG` (0.3° par défaut) pour
éviter de toucher les scans déjà bien cadrés et introduire du bruit inutile.
"""
from __future__ import annotations
from pathlib import Path
from typing import Tuple
import numpy as np
from PIL import Image
try:
import cv2 # type: ignore
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
MIN_ANGLE_DEG = 0.3 # en-dessous, on ne corrige pas
MAX_ANGLE_DEG = 10.0 # au-dessus, c'est anormal → suspect, on ne corrige pas
NEAR_HORIZONTAL_BAND = 15.0 # degrés : bande autour de l'horizontale pour filtrer
def detect_skew_angle(img: Image.Image) -> float:
"""Retourne l'angle de skew en degrés (positif = tourné dans le sens
des aiguilles d'une montre) à appliquer pour redresser l'image.
Si aucune ligne horizontale n'est trouvée, retourne 0.0.
Si l'angle détecté est hors [-MAX_ANGLE_DEG, +MAX_ANGLE_DEG], retourne 0.0
(probablement une erreur de détection, on ne corrige pas).
"""
if not _HAS_CV2:
return 0.0
gray = np.array(img.convert("L"))
# Réduire l'image pour accélérer (max 1500 px de large)
h, w = gray.shape
if w > 1500:
scale = 1500 / w
gray = cv2.resize(gray, (1500, int(h * scale)), interpolation=cv2.INTER_AREA)
# Canny edges — paramètres standards documents
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# Hough Lines probabiliste : rapide et robuste
lines = cv2.HoughLinesP(
edges, rho=1, theta=np.pi / 180, threshold=200,
minLineLength=gray.shape[1] // 4, # au moins 25% de la largeur
maxLineGap=20,
)
if lines is None or len(lines) == 0:
return 0.0
# Calculer l'angle de chaque ligne en degrés
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
if x2 == x1:
continue # ligne verticale, ignorée
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
# On ne garde que les lignes proches de l'horizontale
if abs(angle) < NEAR_HORIZONTAL_BAND:
angles.append(angle)
if not angles:
return 0.0
# Moyenne robuste : médiane plutôt que mean, moins sensible aux outliers
angle = float(np.median(angles))
if abs(angle) > MAX_ANGLE_DEG:
return 0.0 # suspect → on ne corrige pas
return angle
def deskew_image(img: Image.Image,
angle: float | None = None,
min_angle: float = MIN_ANGLE_DEG) -> Tuple[Image.Image, float]:
"""Redresse une image si le skew détecté dépasse `min_angle`.
Retourne (image_eventuellement_rotee, angle_applique).
Si |angle| < min_angle, retourne l'image inchangée et angle=0.0.
"""
if angle is None:
angle = detect_skew_angle(img)
if abs(angle) < min_angle:
return img, 0.0
# PIL.Image.rotate : positive angle = counter-clockwise
# detect_skew retourne positif = clockwise → on inverse pour PIL
rotated = img.rotate(
angle,
resample=Image.Resampling.BICUBIC,
expand=False,
fillcolor="white",
)
return rotated, angle
def deskew_file(src: Path, dst: Path | None = None,
min_angle: float = MIN_ANGLE_DEG) -> float:
"""Version fichier → fichier. Écrase `src` si `dst` est None.
Retourne l'angle appliqué (0.0 si pas de rotation)."""
img = Image.open(src)
rotated, angle = deskew_image(img, min_angle=min_angle)
out = dst or src
rotated.save(out, "PNG", optimize=True)
return angle
if __name__ == "__main__":
import sys
import glob
paths = [Path(p) for p in (sys.argv[1:] or sorted(glob.glob(".cache/images/*/page_01.png")))]
print(f"Deskew sur {len(paths)} images (seuil={MIN_ANGLE_DEG}°)...")
total_corrected = 0
for p in paths:
angle = detect_skew_angle(Image.open(p))
mark = "" if abs(angle) >= MIN_ANGLE_DEG else "·"
if abs(angle) >= MIN_ANGLE_DEG:
total_corrected += 1
print(f" {mark} {p} : {angle:+.2f}°")
print(f"\n{total_corrected}/{len(paths)} images auraient besoin d'un redressement.")

View File

@@ -1,117 +1,84 @@
"""Orchestration d'extraction pour un dossier OGC."""
import json
import re
"""Orchestration d'extraction pour un dossier OGC.
Chaîne les étages du pipeline sans connaître leur implémentation interne :
ingest → routing → OCR page par page → enrichissement page-spécifique → validation ATIH
L'orchestration elle-même ne contient aucune logique métier : elle délègue à
`pipeline.recueil`, `pipeline.validation`, `pipeline.classify`, `pipeline.ocr_qwen`
et `pipeline.prompts`. Cela permet de tester indépendamment chaque étage.
"""
from __future__ import annotations
import time
from pathlib import Path
from .ingest import pdf_to_images
from .classify import detect_page_type, route_by_index
from .ingest import pdf_to_images
from .json_utils import parse_json_output
from .ocr_qwen import QwenVLOCR
from .prompts import PAGE_TYPES, PROMPT_HEADER
from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD
from .recueil import enrich_recueil, resolve_recueil_zones
from .validation import annotate as validate_annotate
_EMPTY_OBJ_PATTERN = re.compile(
r'\{\s*"code"\s*:\s*""\s*,\s*"position"\s*:\s*""\s*(?:,\s*"libelle"\s*:\s*""\s*)?\}',
re.DOTALL,
)
def _run_page_ocr(ocr: QwenVLOCR, image_path: Path, ptype: str) -> tuple[dict | None, str, float]:
"""Exécute le prompt principal associé à un type de page et parse le JSON.
def _truncate_empty_loop(text: str, max_consecutive: int = 2) -> str:
"""Détecte et tronque les boucles d'objets vides.
GLM-OCR peut boucler sur `{"code":"", "position":"", "libelle":""}` quand
un tableau DAS ou actes est vide dans l'image. La sortie est alors
tronquée à `max_new_tokens` sans fermer le JSON → parse error.
On garde au plus `max_consecutive` objets vides puis on coupe.
Retourne (parsed_dict_ou_None, ocr_raw, elapsed_s). `parsed=None` quand
la page n'a pas de prompt structuré associé (concertation_med, hospit.).
"""
matches = list(_EMPTY_OBJ_PATTERN.finditer(text))
if len(matches) <= max_consecutive:
return text
# On coupe après la fin du `max_consecutive`-ième match
cut_at = matches[max_consecutive - 1].end()
return text[:cut_at]
conf = PAGE_TYPES.get(ptype)
if not conf or conf["prompt"] == PROMPT_HEADER:
return None, "", 0.0
res = ocr.run(image_path, conf["prompt"], max_new_tokens=4096)
parsed = parse_json_output(res["text"])
return parsed, res["text"], round(res["elapsed_s"], 2)
def _close_open_json(text: str) -> str:
"""Ajoute les brackets/braces manquants pour tenter de fermer un JSON tronqué."""
# Compte les brackets non balancés en ignorant ceux entre guillemets simples/doubles
depth_brace = 0
depth_bracket = 0
in_string = False
escape = False
for c in text:
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == "{": depth_brace += 1
elif c == "}": depth_brace -= 1
elif c == "[": depth_bracket += 1
elif c == "]": depth_bracket -= 1
# Retirer les virgules traînantes
closed = text.rstrip().rstrip(",")
# Fermer en priorité les crochets ouverts (tableaux), puis les accolades
closed += "]" * max(0, depth_bracket)
closed += "}" * max(0, depth_brace)
return closed
def _resolve_routing(images: list[Path], ocr: QwenVLOCR,
use_standard_routing: bool,
verbose: bool) -> tuple[list[str | None], list[str]]:
"""Détermine le type de chaque page, soit par ordre standard (une seule
vérification sur la page 1), soit par classification OCR page par page.
def parse_json_output(raw: str) -> dict | None:
"""Tente d'extraire un JSON depuis la sortie GLM-OCR.
Stratégies successives :
1. parse direct après retrait des fences ```json
2. patch des virgules manquantes entre objets / tableaux
3. détection et troncature des boucles d'objets vides (cas fréquent sur
tableaux DAS/actes vides → boucle jusqu'à max_new_tokens)
4. fermeture des structures JSON ouvertes après troncature
Retourne (page_types, headers). `headers[i]` est vide si pas de classify
effectuée sur la page i.
"""
if not raw:
return None
text = raw.strip()
# 1) fences markdown
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
page_types: list[str | None] = [None] * len(images)
headers: list[str] = [""] * len(images)
# 2) virgules manquantes entre `} {` et `] [`
patched = re.sub(r"\}\s*\n(\s*\{)", r"},\n\1", text)
patched = re.sub(r"\]\s*\n(\s*\[)", r"],\n\1", patched)
try:
return json.loads(patched)
except json.JSONDecodeError:
pass
if use_standard_routing and images:
ptype1, header1 = detect_page_type(images[0], ocr)
if ptype1 == "recueil":
page_types = list(route_by_index(len(images)))
headers[0] = header1
if verbose:
print(" routing standard (page 1 = recueil OK)")
return page_types, headers
if verbose:
print(f" page 1 = {ptype1} → fallback classification")
# 3) troncature des boucles d'objets vides puis 4) fermeture
trimmed = _truncate_empty_loop(patched)
closed = _close_open_json(trimmed)
try:
result = json.loads(closed)
result["_truncated_loop"] = True # trace de l'intervention
return result
except json.JSONDecodeError as e:
return {"_raw": raw, "_parse_error": str(e)}
# Fallback : classify page par page
for i, img in enumerate(images):
page_types[i], headers[i] = detect_page_type(img, ocr)
return page_types, headers
def extract_dossier(pdf_path: str | Path, verbose: bool = True,
use_standard_routing: bool = True) -> dict:
"""Pipeline complet d'un dossier : PDF → JSON structuré.
"""Pipeline complet d'un dossier : PDF → JSON structuré + annoté ATIH.
use_standard_routing=True (défaut) : route les pages par index selon
l'ordre standard OGC (6 pages), sans OCR de classification. -50% du temps.
Vérifie uniquement la page 1 pour s'assurer qu'on commence bien par
"recueil" — si non, bascule en classification complète (fallback).
Étages :
1. `ingest.pdf_to_images` : PDF → PNG 300 dpi (avec deskew auto, cache)
2. `_resolve_routing` : type de chaque page
3. `_run_page_ocr` : OCR du schéma structuré par type de page
4. `recueil.enrich_recueil` : checkboxes + crop Recodage pour la page recueil
5. `validation.annotate` : validation ATIH de tous les codes extraits
Paramètre `use_standard_routing=True` exploite l'ordre standard des 6 pages
OGC et économise 5 appels OCR par dossier. Bascule automatique sur la
classification page-à-page si la page 1 n'est pas le recueil attendu.
"""
pdf_path = Path(pdf_path)
ocr = QwenVLOCR()
@@ -122,37 +89,22 @@ def extract_dossier(pdf_path: str | Path, verbose: bool = True,
if verbose:
print(f"[{pdf_path.name}] {len(images)} pages converties")
# Choix de stratégie de routing
page_types = [None] * len(images)
headers = [""] * len(images)
if use_standard_routing:
# Vérif rapide sur la page 1 (seul OCR de classification)
ptype1, header1 = detect_page_type(images[0], ocr)
if ptype1 == "recueil":
page_types = route_by_index(len(images))
headers[0] = header1
if verbose:
print(f" routing standard (page 1 = recueil OK)")
else:
if verbose:
print(f" page 1 = {ptype1} → fallback classification")
use_standard_routing = False
page_types, headers = _resolve_routing(images, ocr, use_standard_routing, verbose)
result = {
_, cb_zones = resolve_recueil_zones()
result: dict = {
"fichier": pdf_path.stem,
"pdf_hash": images[0].parent.name,
"pdf_hash": images[0].parent.name if images else "",
"pages": [],
"extraction": {},
}
for idx, img_path in enumerate(images, 1):
t0 = time.time()
if use_standard_routing:
ptype = page_types[idx - 1]
header_text = headers[idx - 1]
else:
ptype, header_text = detect_page_type(img_path, ocr)
page_info = {
ptype = page_types[idx - 1]
header_text = headers[idx - 1]
page_info: dict = {
"page": idx,
"type": ptype,
"header": header_text.strip(),
@@ -161,31 +113,20 @@ def extract_dossier(pdf_path: str | Path, verbose: bool = True,
if verbose:
print(f" p{idx}: {ptype}")
prompt_conf = PAGE_TYPES.get(ptype)
if prompt_conf and prompt_conf["prompt"] != PROMPT_HEADER:
res = ocr.run(img_path, prompt_conf["prompt"], max_new_tokens=4096)
parsed = parse_json_output(res["text"])
page_info["ocr_raw"] = res["text"]
parsed, ocr_raw, elapsed = _run_page_ocr(ocr, img_path, ptype) if ptype else (None, "", 0.0)
if parsed is not None:
page_info["ocr_raw"] = ocr_raw
page_info["parsed"] = parsed
page_info["elapsed_s"] = round(res["elapsed_s"], 2)
page_info["elapsed_s"] = elapsed
# Enrichissement : checkboxes accord/désaccord sur la fiche recueil
# (GLM-OCR ne sait pas lire les checkboxes — voir test_prompt_crop_v2.py)
if ptype == "recueil" and isinstance(parsed, dict):
cb = detect_accord_desaccord(img_path, RECUEIL_ACCORD_DESACCORD)
parsed["accord_desaccord"] = cb["decision"]
parsed["_checkbox_debug"] = cb # ratios + diff pour audit
enrich_recueil(parsed, img_path, ocr, cb_zones)
page_info["parsed"] = parsed
# Indexer par type pour accès direct dans result["extraction"]
result["extraction"][ptype] = parsed
else:
# Pages non structurées : juste l'en-tête déjà OCR
page_info["elapsed_s"] = round(time.time() - t0, 2)
result["pages"].append(page_info)
# Post-traitement : validation ATIH de tous les codes extraits
result = validate_annotate(result)
return result
return validate_annotate(result)

View File

@@ -1,10 +1,16 @@
"""PDF → images PNG 300 dpi avec cache par hash SHA256."""
"""PDF → images PNG 300 dpi avec cache par hash SHA256.
Applique optionnellement un deskew automatique (redressement) sur chaque page
pour corriger le biais d'inclinaison des scans. Voir pipeline/deskew.py.
"""
import hashlib
import os
from pathlib import Path
from pdf2image import convert_from_path
from PIL import Image
from .deskew import deskew_image, MIN_ANGLE_DEG
DEFAULT_DPI = 300
CACHE_ROOT = Path(".cache/images")
@@ -18,23 +24,37 @@ def pdf_hash(pdf_path: str) -> str:
return h.hexdigest()[:16]
def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI, cache_root: Path = CACHE_ROOT) -> list[Path]:
def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI,
cache_root: Path = CACHE_ROOT,
deskew: bool = True) -> list[Path]:
"""Convertit un PDF en PNG 300 dpi. Retourne la liste des chemins (1 par page).
Le cache est indexé par hash du PDF : un PDF inchangé n'est jamais reconverti.
Avec `deskew=True` (défaut), chaque page est redressée si son angle de skew
dépasse le seuil défini dans `pipeline.deskew.MIN_ANGLE_DEG` (0.3°). L'angle
appliqué est persisté dans un fichier `<page>.skew` à côté (pour audit).
"""
cache_root = Path(cache_root)
h = pdf_hash(pdf_path)
out_dir = cache_root / h
out_dir.mkdir(parents=True, exist_ok=True)
existing = sorted(out_dir.glob("page_*.png"))
# Le glob est strict pour ne pas attraper les crops intermédiaires
# (page_XX_recodage.png, etc.)
existing = sorted(p for p in out_dir.glob("page_*.png")
if p.stem.replace("page_", "").isdigit())
if existing:
return existing
pages = convert_from_path(pdf_path, dpi)
paths = []
for i, img in enumerate(pages, 1):
if deskew:
img, applied = deskew_image(img)
if applied != 0.0:
# Trace d'audit : on note l'angle corrigé
(out_dir / f"page_{i:02d}.skew").write_text(f"{applied:.3f}\n")
p = out_dir / f"page_{i:02d}.png"
img.save(p, "PNG", optimize=True)
paths.append(p)

136
pipeline/json_utils.py Normal file
View File

@@ -0,0 +1,136 @@
"""Parsing JSON tolérant pour les sorties des VLM.
Les VLM (Qwen, GLM-OCR, GOT-OCR…) produisent du JSON avec des anomalies
fréquentes :
- Encadrement par des fences markdown ```json ... ```
- Virgules manquantes entre objets ou éléments de tableau
- Boucles pathologiques d'objets vides `{"code":"","position":""}` répétés
jusqu'à `max_new_tokens`, ce qui tronque le JSON sans le fermer proprement
Ce module expose `parse_json_output()` qui applique plusieurs stratégies de
récupération avant d'abandonner. En dernier recours, il renvoie un dict avec
`_raw` + `_parse_error` pour audit, jamais `None` (ce qui casserait le pipeline).
"""
from __future__ import annotations
import json
import re
# Pattern qui matche un objet vide générique {"code":"","position":"",...}
_EMPTY_OBJ_PATTERN = re.compile(
r'\{\s*"code"\s*:\s*""\s*,\s*"position"\s*:\s*""\s*(?:,\s*"libelle"\s*:\s*""\s*)?\}',
re.DOTALL,
)
_FENCE_OPEN_RE = re.compile(r"^```(?:json)?\s*")
_FENCE_CLOSE_RE = re.compile(r"\s*```$")
_MISSING_COMMA_OBJ_RE = re.compile(r"\}\s*\n(\s*\{)")
_MISSING_COMMA_ARR_RE = re.compile(r"\]\s*\n(\s*\[)")
def strip_fences(text: str) -> str:
"""Retire un éventuel encadrement ```json ... ```."""
text = _FENCE_OPEN_RE.sub("", text.strip())
text = _FENCE_CLOSE_RE.sub("", text)
return text
def patch_missing_commas(text: str) -> str:
"""Ajoute les virgules manquantes entre `}\\n{` et `]\\n[`.
Les VLM omettent fréquemment ces virgules dans leurs sorties JSON.
"""
text = _MISSING_COMMA_OBJ_RE.sub(r"},\n\1", text)
text = _MISSING_COMMA_ARR_RE.sub(r"],\n\1", text)
return text
def truncate_empty_loop(text: str, max_consecutive: int = 2) -> str:
"""Tronque les boucles d'objets vides (`{"code":"","position":""}` répété).
Cas d'usage : quand un tableau DAS ou Actes est vide dans l'image, le
VLM a parfois tendance à générer le même objet vide en boucle jusqu'à
saturer `max_new_tokens`. La sortie est alors tronquée sans fermer le
JSON → parse error. Garder au maximum `max_consecutive` occurrences.
"""
matches = list(_EMPTY_OBJ_PATTERN.finditer(text))
if len(matches) <= max_consecutive:
return text
cut_at = matches[max_consecutive - 1].end()
return text[:cut_at]
def close_open_json(text: str) -> str:
"""Ajoute les brackets/braces manquants pour fermer un JSON tronqué.
Compte les accolades et crochets non-balancés en ignorant ceux à
l'intérieur d'une chaîne, puis ferme dans le bon ordre (tableaux
ouverts d'abord, puis objets).
"""
depth_brace = 0
depth_bracket = 0
in_string = False
escape = False
for c in text:
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == "{":
depth_brace += 1
elif c == "}":
depth_brace -= 1
elif c == "[":
depth_bracket += 1
elif c == "]":
depth_bracket -= 1
closed = text.rstrip().rstrip(",")
closed += "]" * max(0, depth_bracket)
closed += "}" * max(0, depth_brace)
return closed
def parse_json_output(raw: str) -> dict | None:
"""Parse une sortie VLM en dict. Applique plusieurs stratégies :
1. strip des fences markdown ```json
2. parse direct
3. patch des virgules manquantes
4. troncature des boucles d'objets vides + fermeture du JSON
En cas d'échec de toutes les stratégies, retourne
`{"_raw": raw, "_parse_error": str}` pour permettre l'audit manuel
plutôt que de casser le pipeline.
"""
if not raw:
return None
text = strip_fences(raw)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
patched = patch_missing_commas(text)
try:
return json.loads(patched)
except json.JSONDecodeError:
pass
trimmed = truncate_empty_loop(patched)
closed = close_open_json(trimmed)
try:
result = json.loads(closed)
if isinstance(result, dict):
result["_truncated_loop"] = True
return result
except json.JSONDecodeError as e:
return {"_raw": raw, "_parse_error": str(e)}

View File

@@ -27,18 +27,64 @@ class QwenVLOCR:
def _init_model(self):
t0 = time.time()
import os as _os
# max_pixels limite le nombre de patches visuels pour éviter l'OOM
# sur images 300 dpi (2481x3509). ~1.25M pixels = équilibre qualité/VRAM.
# sur images 300 dpi (2481x3509). ~800 patches = équilibre qualité/VRAM,
# tient confortablement dans ~5-6 Go même avec d'autres processus GPU
# en arrière-plan. Configurable via env var QWEN_MAX_PIXELS (en patches).
max_pixels = int(_os.environ.get("QWEN_MAX_PIXELS", 800)) * 28 * 28
self.processor = AutoProcessor.from_pretrained(
MODEL_PATH,
min_pixels=256 * 28 * 28,
max_pixels=1280 * 28 * 28,
)
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_PATH,
torch_dtype=torch.bfloat16,
device_map="auto",
max_pixels=max_pixels,
)
# Device : "auto" par défaut (GPU si dispo), "cpu" pour forcer le CPU
# quand la VRAM est saturée par d'autres process. Configurable via
# QWEN_DEVICE=cpu.
device = _os.environ.get("QWEN_DEVICE", "auto").lower()
if device == "cpu":
# Sur CPU on cherche à maximiser le throughput :
# 1. Utiliser tous les cores via torch.set_num_threads (set_num_threads
# prime sur OMP_NUM_THREADS pour les ops PyTorch natifs).
# 2. Choisir bfloat16 si le CPU le supporte nativement (Zen 5,
# Zen 4, Intel Sapphire Rapids+ ont AVX-512 BF16). Sinon float32.
n_threads = int(_os.environ.get("TORCH_NUM_THREADS", _os.cpu_count() or 8))
torch.set_num_threads(n_threads)
try:
torch.set_num_interop_threads(n_threads)
except RuntimeError:
pass # déjà initialisé, ignorer
# Détection AVX-512 BF16 via /proc/cpuinfo (Linux)
use_bf16 = False
try:
with open("/proc/cpuinfo") as f:
flags = f.read()
use_bf16 = "avx512_bf16" in flags or "amx_bf16" in flags
except Exception:
pass
dtype = torch.bfloat16 if use_bf16 else torch.float32
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_PATH,
torch_dtype=dtype,
device_map={"": "cpu"},
low_cpu_mem_usage=True,
)
self.device_used = "cpu"
self.cpu_threads = n_threads
self.cpu_dtype = str(dtype).replace("torch.", "")
else:
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_PATH,
torch_dtype=torch.bfloat16,
device_map="auto",
)
self.device_used = "cuda" if torch.cuda.is_available() else "cpu"
self.cpu_threads = None
self.cpu_dtype = None
self.model.eval()
self.load_time = time.time() - t0
self.vram_gb = torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
@@ -67,4 +113,9 @@ class QwenVLOCR:
generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
out_ids = generated_ids[:, inputs.input_ids.shape[1]:]
output = self.processor.batch_decode(out_ids, skip_special_tokens=True)[0]
# Libérer la VRAM allouée par l'inférence (utile quand d'autres
# processus tournent en parallèle sur le même GPU)
del inputs, generated_ids, out_ids
if torch.cuda.is_available():
torch.cuda.empty_cache()
return {"text": output.strip(), "elapsed_s": time.time() - t0}

View File

@@ -9,9 +9,10 @@ DEFAULT_OUT = Path("output/v2")
def save_result(result: dict, out_dir: Path | str = DEFAULT_OUT) -> Path:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
from .ocr_qwen import MODEL_PATH as OCR_MODEL_ID
result["_meta"] = {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"pipeline_version": "v2",
"ocr_model": OCR_MODEL_ID,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
out_path = out_dir / f"{result['fichier']}.json"

View File

@@ -54,6 +54,38 @@ Si un champ est illisible, laisse une chaîne vide. Ne devine pas.
"praticien_conseil": ""
}"""
# --- Second passage dédié : colonne Recodage de la page recueil ---
# Qwen-VL sous-extrait la colonne droite du tableau Codage quand on lui donne
# la page entière (27% de couverture sur `codage_reco.dp` en V2.0). En lui
# donnant directement un crop zonal de cette seule colonne, il lit beaucoup
# mieux (la structure à une seule colonne lève l'ambiguïté).
#
# Zone cropée (coordonnées relatives dans l'image complète) :
# Zone restreinte au seul bloc codage (DP/DR/DAS de la colonne Recodage).
# On exclut la partie Actes (qui commence autour de y=0.680) pour éviter que
# Qwen confonde les codes CCAM (actes) avec des codes CIM-10 (DAS).
RECUEIL_RECODAGE_ZONE = (0.77, 0.330, 0.97, 0.490)
SCHEMA_RECUEIL_RECODAGE = """Cette image est un extrait d'une colonne d'un tableau médical.
La colonne peut contenir ZÉRO, UN ou PLUSIEURS codes médicaux CIM-10 (format : 1 lettre majuscule + 2 à 4 chiffres, ex: K650, T810, Z954, R31, I652). Un code peut avoir un suffixe `*`. À droite d'un code, une position numérique (1-9) peut être visible.
IMPORTANT — LIS UNIQUEMENT CE QUI EST PHYSIQUEMENT VISIBLE :
- La plupart des lignes de ce tableau sont VIDES. C'est NORMAL.
- Ne liste QUE les codes effectivement écrits dans l'image. N'INVENTE rien.
- Si l'image ne contient qu'un seul code, ta réponse doit lister exactement un code (pas plusieurs).
- Si l'image ne contient aucun code, renvoie `"codes": []`.
- Ne déduis pas les codes d'autres cases non montrées dans l'image.
Pour chaque code réellement visible, indique sa position à droite si elle est écrite, sinon "".
Renvoie STRICTEMENT ce JSON, sans commentaire ni markdown :
{
"codes": [
{"code": "", "position": ""}
]
}"""
# --- Page 5 : Fiche administrative de concertation 2/2 (décision finale) ---
SCHEMA_CONCERTATION_2 = """Lis la fiche de concertation et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
Si un champ est illisible, laisse une chaîne vide.

203
pipeline/recueil.py Normal file
View File

@@ -0,0 +1,203 @@
"""Logique spécifique à la page `recueil` (fiche médicale de recueil OGC).
Regroupe tout ce qui concerne cette page — la plus riche et la plus difficile
à extraire du dossier OGC — séparé de l'orchestration générale :
- Résolution des zones configurables (crop Recodage, checkboxes)
- Second passage VLM sur le crop de la colonne Recodage
- Fusion du résultat crop dans le JSON principal
- Classification des codes CIM-10 en DP/DR/DAS par règle métier
- Enrichissement post-extraction (checkboxes Accord/Désaccord, ghs_injustifie)
Les fonctions sont testables indépendamment de Qwen quand on leur fournit
déjà les sorties OCR brutes.
"""
from __future__ import annotations
import re
import time
from pathlib import Path
from typing import Any
from PIL import Image
from .checkboxes import (
CheckboxZones,
RECUEIL_ACCORD_DESACCORD,
detect_accord_desaccord,
parse_ghs_injustifie,
)
from .json_utils import parse_json_output
from .ocr_qwen import QwenVLOCR
from .prompts import RECUEIL_RECODAGE_ZONE, SCHEMA_RECUEIL_RECODAGE
from .zones_config import get_zone, load_config
# ============================================================
# Résolution des zones (config JSON + fallback sur les defaults)
# ============================================================
def resolve_recueil_zones() -> tuple[tuple[float, float, float, float], CheckboxZones]:
"""Charge les zones de la page recueil depuis la config utilisateur,
avec fallback sur les constantes compilées si la config est absente.
Retourne (zone_crop_recodage, zones_accord_desaccord).
"""
cfg = load_config()
reco = get_zone("recueil", "codage_reco", cfg) or RECUEIL_RECODAGE_ZONE
acc = get_zone("recueil", "accord_checkbox", cfg)
des = get_zone("recueil", "desaccord_checkbox", cfg)
if acc and des:
cb = CheckboxZones(accord=acc, desaccord=des)
else:
cb = RECUEIL_ACCORD_DESACCORD
return reco, cb
# ============================================================
# Classification CIM-10 → DP / DR / DAS (pur, testable sans VLM)
# ============================================================
CIM10_RE = re.compile(r"^[A-Z]\d{2,4}\s*\*?\s*\+?\d*$")
def filter_cim10_codes(codes_raw: list[Any]) -> list[dict]:
"""Filtre une liste de codes OCR bruts pour ne garder que les CIM-10.
Les VLM peuvent parfois lire des codes CCAM (actes) dans un crop qui
dépasse sur le bloc Actes. On les retire ici pour ne pas polluer les DAS.
"""
kept = []
for c in codes_raw or []:
if not isinstance(c, dict):
continue
code = (c.get("code") or "").strip()
if code and CIM10_RE.match(code):
kept.append({
"code": code,
"position": str(c.get("position") or "").strip(),
})
return kept
def classify_codes_dp_dr_das(codes: list[dict]) -> tuple[str, str, list[dict]]:
"""Classifie une liste de codes {code, position} en DP, DR et liste de DAS.
Règle métier :
- 1er code sans position → DP
- 2e code sans position → DR (ignoré si identique au DP : le VLM peut
dupliquer le DP quand la case DR est visuellement vide)
- tous les codes avec position → DAS
- codes sans position au-delà du 2e → DAS sans position (pour ne rien perdre)
"""
dp, dr = "", ""
das: list[dict] = []
dp_assigned = dr_assigned = False
for c in codes:
code, position = c["code"], c["position"]
if not position:
if not dp_assigned:
dp, dp_assigned = code, True
elif not dr_assigned:
if code == dp:
dr_assigned = True # doublon DP → DR vide
else:
dr, dr_assigned = code, True
else:
das.append({"code": code, "position": ""})
else:
das.append(c)
return dp, dr, das
# ============================================================
# Second passage VLM sur crop Recodage
# ============================================================
def run_recodage_crop_pass(image_path: Path, ocr: QwenVLOCR,
zone: tuple[float, float, float, float] | None = None
) -> dict | None:
"""Execute un second passage VLM sur le crop zonal de la colonne Recodage.
Sauvegarde le crop à côté de l'image source (suffixe `_recodage.png`)
pour audit. Retourne un dict avec `dp/dr/das` + métadonnées, ou None
en cas d'échec d'OCR ou de parsing.
"""
try:
img = Image.open(image_path)
w, h = img.size
z = zone
if z is None:
z, _ = resolve_recueil_zones()
x1, y1, x2, y2 = z
crop = img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h)))
crop_path = image_path.parent / f"{image_path.stem}_recodage.png"
crop.save(crop_path)
except Exception:
return None
t0 = time.time()
res = ocr.run(crop_path, SCHEMA_RECUEIL_RECODAGE, max_new_tokens=1024)
parsed = parse_json_output(res["text"])
if not isinstance(parsed, dict) or "_parse_error" in parsed:
return None
codes = filter_cim10_codes(parsed.get("codes") or [])
dp, dr, das = classify_codes_dp_dr_das(codes)
return {
"dp": dp, "dr": dr, "das": das,
"_source": "crop_recodage",
"_elapsed_s": round(res["elapsed_s"], 2),
"_n_codes_raw": len(parsed.get("codes") or []),
"_n_codes_kept": len(codes),
}
def merge_codage_reco(parsed: dict, reco: dict) -> None:
"""Fusionne le résultat du crop Recodage dans `parsed["codage_reco"]`.
Politique de merge : le crop est plus fiable (contexte isolé) donc il
prime sur le passage principal. Exception : si un champ du crop est vide
mais que le passage principal l'a rempli, on garde celui du passage
principal (on ne dégrade jamais un résultat existant).
"""
existing = parsed.get("codage_reco") if isinstance(parsed.get("codage_reco"), dict) else {}
parsed["codage_reco"] = {
"dp": reco.get("dp", "") or existing.get("dp", ""),
"dr": reco.get("dr", "") or existing.get("dr", ""),
"das": reco.get("das") or existing.get("das") or [],
}
parsed.setdefault("_crop_recodage", {})["result"] = reco
# ============================================================
# Enrichissement post-extraction d'une page recueil
# ============================================================
def enrich_recueil(parsed: dict, image_path: Path, ocr: QwenVLOCR,
cb_zones: CheckboxZones | None = None) -> dict:
"""Enrichit un JSON recueil parsé avec :
- checkbox accord/désaccord (méthode densité pixels, indépendante du VLM)
- normalisation `ghs_injustifie` → 0 / 1 / ""
- second passage VLM sur le crop Recodage si besoin, fusionné dans `codage_reco`
Modifie `parsed` en place et le renvoie (pratique pour chaînage).
"""
if not isinstance(parsed, dict):
return parsed
zones = cb_zones or resolve_recueil_zones()[1]
# Checkboxes accord / désaccord
cb = detect_accord_desaccord(image_path, zones)
parsed["accord_desaccord"] = cb["decision"]
parsed["_checkbox_debug"] = cb
# Normalisation ghs_injustifie
parsed["ghs_injustifie"] = parse_ghs_injustifie(parsed.get("ghs_injustifie", ""))
# Second passage Recodage
reco = run_recodage_crop_pass(image_path, ocr)
if reco:
merge_codage_reco(parsed, reco)
return parsed

185
pipeline/schema.py Normal file
View File

@@ -0,0 +1,185 @@
"""Schema de sortie stable du pipeline + fonction de nettoyage.
Le pipeline produit un JSON riche pendant l'exécution (avec des champs de debug :
ratios checkbox, OCR raw, flags _parse_error, _truncated_loop, _crop_recodage,
_checkbox_debug, _source, etc). Cette information est utile pour auditer un
dossier mais pollue la structure quand on veut exposer le résultat à un
consommateur aval (Excel, dashboard, échange inter-équipes).
Ce module expose :
- `clean_dossier(raw)` : retourne une version propre, lisible et stable,
sans champs de debug. Garde les flags de validation ATIH qui ont une valeur
métier (codes valides, cohérence GHM↔GHS).
- `SCHEMA_VERSION` : version du format (incrémentée à chaque breaking
change de structure).
- `CLEAN_FIELDS_RECUEIL` : liste des champs finaux de la page recueil
(utile pour Excel, dashboard, docs).
Principe : le JSON raw reste dans `output/v2/<nom>.json` (audit complet), le
JSON clean est produit séparément sur demande via `clean_dossier()`.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
SCHEMA_VERSION = "2.0"
# Champs retenus sur la page recueil pour la sortie propre. L'ordre est
# celui de l'affichage logique (en-tête → séjour → codage → GHM/GHS → décisions).
CLEAN_FIELDS_RECUEIL = [
"etablissement", "finess", "date_debut_controle",
"n_ogc", "n_champ", "dates_sejour",
"sejour_etab", "sejour_reco", "rum_etab",
"codage_etab", "codage_reco",
"actes_etab", "actes_reco",
"ghm_etab", "ghs_etab", "ghm_reco", "ghs_reco",
"recodage_impactant", "ghs_injustifie",
"accord_desaccord", "praticien_conseil",
]
CLEAN_FIELDS_CONCERTATION_2 = [
"ghs_initial", "ghs_avant_concertation", "ghs_final",
"decision", "date_concertation",
"praticien_controleur", "medecin_dim",
]
CLEAN_FIELDS_CONCERTATION_1 = [
"date_concertation", "argumentaire",
]
CLEAN_FIELDS_PREUVES = [
"date", "praticien_controleur", "medecin_dim", "pieces",
]
# Champs de debug à retirer systématiquement du clean
DEBUG_FIELDS = {
"_checkbox_debug",
"_crop_recodage",
"_parse_error",
"_raw",
"_truncated_loop",
"_source",
"_elapsed_s",
"_n_codes_raw",
"_n_codes_kept",
}
def _pick(d: dict, keys: list[str]) -> dict:
"""Retourne un dict ordonné avec uniquement les clés présentes."""
out = {}
for k in keys:
if k in d:
out[k] = d[k]
return out
def _clean_validation(validation: dict | None) -> dict | None:
"""Garde la validation ATIH mais en format compact : juste les flags utiles."""
if not isinstance(validation, dict):
return None
summary = validation.get("summary") or {}
cc = validation.get("cross_checks") or {}
# On conserve juste l'essentiel : par champ, le flag valid (True/False/None)
# et éventuellement la suggestion de correction OCR.
def _compact_code(entry):
if not isinstance(entry, dict) or "valid" not in entry:
return None
out = {"valid": entry.get("valid")}
if entry.get("suggestion"):
out["suggestion"] = entry["suggestion"]
if entry.get("libelle_ref"):
out["libelle_ref"] = entry["libelle_ref"]
return out
result = {
"summary": summary,
"codage_etab": {
"dp": _compact_code(validation.get("codage_etab", {}).get("dp")),
"dr": _compact_code(validation.get("codage_etab", {}).get("dr")),
"das": [_compact_code(d) for d in validation.get("codage_etab", {}).get("das", []) or []],
},
"codage_reco": {
"dp": _compact_code(validation.get("codage_reco", {}).get("dp")),
"dr": _compact_code(validation.get("codage_reco", {}).get("dr")),
"das": [_compact_code(d) for d in validation.get("codage_reco", {}).get("das", []) or []],
},
"ghm_etab": _compact_code(validation.get("ghm_etab")),
"ghs_etab": _compact_code(validation.get("ghs_etab")),
"ghm_reco": _compact_code(validation.get("ghm_reco")),
"ghs_reco": _compact_code(validation.get("ghs_reco")),
"cross_checks": {
"etab_ghm_ghs_coherent": cc.get("etab", {}).get("coherent"),
"reco_ghm_ghs_coherent": cc.get("reco", {}).get("coherent"),
},
}
return result
def _clean_recueil(page: dict) -> dict:
cleaned = _pick(page, CLEAN_FIELDS_RECUEIL)
# Sous-champs codage : nettoyer aussi les codes invalides
v = _clean_validation(page.get("_validation"))
if v:
cleaned["_validation"] = v
return cleaned
def _clean_simple(page: dict, fields: list[str]) -> dict:
cleaned = _pick(page, fields)
v = page.get("_validation")
if isinstance(v, dict):
cleaned["_validation"] = v # déjà compact pour ces pages
return cleaned
def clean_dossier(raw: dict) -> dict:
"""Retourne une copie nettoyée d'un résultat de pipeline.
Strippe les champs de debug internes, garde la validation ATIH compacte
et une structure stable.
"""
extraction = raw.get("extraction") or {}
clean_extraction: dict[str, Any] = {}
if "recueil" in extraction and isinstance(extraction["recueil"], dict):
clean_extraction["recueil"] = _clean_recueil(extraction["recueil"])
if "concertation_2" in extraction and isinstance(extraction["concertation_2"], dict):
clean_extraction["concertation_2"] = _clean_simple(
extraction["concertation_2"], CLEAN_FIELDS_CONCERTATION_2)
if "concertation_1" in extraction and isinstance(extraction["concertation_1"], dict):
clean_extraction["concertation_1"] = _clean_simple(
extraction["concertation_1"], CLEAN_FIELDS_CONCERTATION_1)
if "preuves" in extraction and isinstance(extraction["preuves"], dict):
clean_extraction["preuves"] = _clean_simple(
extraction["preuves"], CLEAN_FIELDS_PREUVES)
return {
"fichier": raw.get("fichier"),
"pdf_hash": raw.get("pdf_hash"),
"schema_version": SCHEMA_VERSION,
"extraction": clean_extraction,
"_meta": raw.get("_meta", {}),
}
if __name__ == "__main__":
# Utilitaire : nettoyer un fichier en place, ou produire une version clean
import json, sys, glob
from pathlib import Path
if len(sys.argv) > 1:
paths = [Path(p) for p in sys.argv[1:]]
else:
paths = [Path(p) for p in sorted(glob.glob("output/v2/OGC *.json"))]
out_dir = Path("output/v2_clean")
out_dir.mkdir(exist_ok=True)
for p in paths:
raw = json.loads(p.read_text(encoding="utf-8"))
clean = clean_dossier(raw)
(out_dir / p.name).write_text(
json.dumps(clean, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{len(paths)} fichiers nettoyés → {out_dir}/")

View File

@@ -27,7 +27,44 @@ if str(_REPO_ROOT) not in sys.path:
import streamlit as st
from PIL import Image
# ----------------------------------------------------------------------------
# Compatibility shim : streamlit-drawable-canvas 0.9.3 utilise l'API privée
# `streamlit.elements.image.image_to_url` qui a été retirée à partir de
# Streamlit ≈ 1.49. On réinjecte une implémentation équivalente fondée sur
# un data URI base64, ce qui permet au canvas de continuer à fonctionner
# sans downgrader Streamlit globalement.
#
# Remplacer ce shim par l'upgrade de streamlit-drawable-canvas si une version
# > 0.9.3 est publiée.
# ----------------------------------------------------------------------------
import base64 as _b64
import io as _io
from streamlit.elements import image as _st_image # type: ignore
if not hasattr(_st_image, "image_to_url"):
def _image_to_url_compat(image, width, clamp, channels, output_format,
image_id):
"""Convertit une PIL.Image en data URI compatible avec drawable-canvas."""
fmt = (output_format or "PNG").upper()
if fmt == "JPG":
fmt = "JPEG"
buf = _io.BytesIO()
image.save(buf, format=fmt)
b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
mime = "image/jpeg" if fmt == "JPEG" else f"image/{fmt.lower()}"
return f"data:{mime};base64,{b64}"
_st_image.image_to_url = _image_to_url_compat # type: ignore[attr-defined]
# ----------------------------------------------------------------------------
from pipeline.ingest import pdf_to_images
from pipeline.zones_config import load_config, save_config, DEFAULT_CONFIG_PATH
try:
from streamlit_drawable_canvas import st_canvas
_HAS_CANVAS = True
except ImportError:
_HAS_CANVAS = False
# ============================================================
@@ -268,6 +305,130 @@ def render_page_editor(name: str, ptype: str, extract: dict, gold: dict | None):
st.code(page_meta.get("ocr_raw", ""), language="json")
def render_calibration_page():
"""Mode 'Calibration zones' : dessine des rectangles à la souris sur une
image de référence, sauvegarde dans pipeline/zones_config.json."""
st.header("🔧 Calibration des zones")
if not _HAS_CANVAS:
st.error(
"Le package `streamlit-drawable-canvas` n'est pas installé.\n"
"Installe-le avec : `pip install streamlit-drawable-canvas`"
)
return
pdfs = list_pdfs()
if not pdfs:
st.error("Aucun PDF disponible pour la calibration")
return
col_ctrl, _ = st.columns([1, 3])
with col_ctrl:
ref_name = st.selectbox(
"PDF de référence (bien cadré)",
[p.stem for p in pdfs], key="calib_pdf",
)
page_type = st.selectbox(
"Type de page", ["recueil"],
help="Aujourd'hui seule la page recueil a des zones configurables",
)
# Page numéro selon le type (recueil = page 1)
page_num = {"recueil": 1}.get(page_type, 1)
ref_pdf = next(p for p in pdfs if p.stem == ref_name)
img_path = pdf_to_images(str(ref_pdf))[page_num - 1]
img = Image.open(img_path)
img_w, img_h = img.size
# Charger config existante et préparer les zones
cfg = load_config()
existing_zones = cfg.get(page_type, {})
# On scale l'image pour tenir dans le canvas (largeur ~900 px max)
canvas_w = 900
scale = canvas_w / img_w
canvas_h = int(img_h * scale)
# Préparer les rectangles initiaux depuis la config
initial_rects = []
for zone_name, z in existing_zones.items():
if not isinstance(z, dict): continue
initial_rects.append({
"type": "rect",
"left": z["x1"] * canvas_w,
"top": z["y1"] * canvas_h,
"width": (z["x2"] - z["x1"]) * canvas_w,
"height": (z["y2"] - z["y1"]) * canvas_h,
"fill": "rgba(255, 100, 100, 0.15)",
"stroke": "red",
"strokeWidth": 2,
"label_name": zone_name,
})
st.caption(
"💡 Dessine un rectangle par zone à la souris. Les zones existantes "
"apparaissent déjà pré-dessinées. Tu peux les modifier (drag), "
"en ajouter, ou en supprimer (touche Suppr) puis cliquer sur "
"**Sauvegarder**."
)
drawing_mode = st.radio(
"Mode", ["rect", "transform"], horizontal=True,
format_func=lambda x: {"rect": "✏️ Dessiner", "transform": "🖱 Sélectionner / Déplacer"}[x],
key="calib_drawing_mode",
)
canvas_result = st_canvas(
fill_color="rgba(255, 100, 100, 0.15)",
stroke_width=2,
stroke_color="red",
background_image=img,
update_streamlit=True,
width=canvas_w,
height=canvas_h,
drawing_mode=drawing_mode,
initial_drawing={"objects": initial_rects, "version": "5.2.1"},
key="calib_canvas",
)
# Reconstituer la config à partir des rectangles dessinés
rects = (canvas_result.json_data or {}).get("objects", []) if canvas_result.json_data else []
st.markdown("### Zones détectées")
if not rects:
st.info("Aucun rectangle dessiné.")
return
new_zones = {}
for i, r in enumerate(rects):
if r.get("type") != "rect":
continue
# Récupérer le nom existant si présent, sinon demander
default_name = r.get("label_name") or f"zone_{i+1}"
name = st.text_input(
f"Nom de la zone {i+1}",
value=default_name, key=f"calib_name_{i}",
)
x1 = r["left"] / canvas_w
y1 = r["top"] / canvas_h
x2 = x1 + r["width"] / canvas_w
y2 = y1 + r["height"] / canvas_h
desc = existing_zones.get(name, {}).get("description", "")
desc = st.text_input(
f"Description (optionnel)", value=desc, key=f"calib_desc_{i}",
)
st.caption(f"Coords relatives : ({x1:.3f}, {y1:.3f}) → ({x2:.3f}, {y2:.3f})")
new_zones[name] = {"x1": round(x1, 4), "y1": round(y1, 4),
"x2": round(x2, 4), "y2": round(y2, 4),
"description": desc}
if st.button("💾 Sauvegarder la configuration", type="primary"):
cfg[page_type] = new_zones
path = save_config(cfg)
st.success(f"Configuration sauvegardée : {path}")
st.json(new_zones)
def main():
st.set_page_config(page_title="OGC Overlay", layout="wide")
@@ -280,6 +441,13 @@ def main():
st.title("🩺 Extraction OGC — review & gold set")
# Sélecteur de mode en haut de sidebar
with st.sidebar:
mode = st.radio("Mode", ["📋 Review dossier", "🔧 Calibration zones"])
if mode == "🔧 Calibration zones":
render_calibration_page()
return
pdfs = list_pdfs()
if not pdfs:
st.error(f"Aucun PDF trouvé dans {PDF_DIR}")

90
pipeline/zones_config.py Normal file
View File

@@ -0,0 +1,90 @@
"""Configuration des zones d'extraction éditable via l'overlay UI.
Les coordonnées sont relatives (0..1) dans l'image source. Elles sont chargées
au démarrage du pipeline et utilisées à la place des constantes en dur dans
`pipeline/prompts.py` et `pipeline/checkboxes.py` — avec fallback sur ces
constantes si la config n'est pas présente, pour ne pas casser l'existant.
Structure :
{
"recueil": {
"codage_reco": {"x1":0.77, "y1":0.330, "x2":0.97, "y2":0.490, "description":"..."},
"accord_checkbox": {"x1":..., "y1":..., "x2":..., "y2":..., "description":"..."},
"desaccord_checkbox":{...}
},
"concertation_2": {...}
}
Un fichier unique `zones_config.json` à la racine du projet, ou au chemin pointé
par la variable d'env `OGC_ZONES_CONFIG`.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
DEFAULT_CONFIG_PATH = Path(
os.environ.get("OGC_ZONES_CONFIG", "zones_config.json")
)
# Zones par défaut, identiques aux constantes actuelles dans prompts.py et
# checkboxes.py. Sert de fallback et de "mise à jour initiale" quand le
# fichier n'existe pas encore.
DEFAULTS: dict = {
"recueil": {
"codage_reco": {
"x1": 0.77, "y1": 0.330, "x2": 0.97, "y2": 0.490,
"description": "Colonne Recodage (DP / DR / DAS) — exclut le bloc Actes",
},
"accord_checkbox": {
"x1": 0.588, "y1": 0.838, "x2": 0.622, "y2": 0.860,
"description": "Case à cocher 'Accord'",
},
"desaccord_checkbox": {
"x1": 0.588, "y1": 0.858, "x2": 0.622, "y2": 0.880,
"description": "Case à cocher 'Désaccord'",
},
},
}
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict:
"""Charge la config JSON, ou retourne les defaults si absente."""
if not path.exists():
return _deep_copy(DEFAULTS)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return _deep_copy(DEFAULTS)
# Merge : les defaults sont une base, la config utilisateur vient par-dessus
merged = _deep_copy(DEFAULTS)
for page, zones in raw.items():
merged.setdefault(page, {}).update(zones)
return merged
def save_config(cfg: dict, path: Path = DEFAULT_CONFIG_PATH) -> Path:
path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def get_zone(page_type: str, zone_name: str,
config: dict | None = None) -> tuple[float, float, float, float] | None:
"""Récupère une zone depuis la config ou les defaults.
Retourne (x1, y1, x2, y2) ou None si inconnue.
"""
cfg = config or load_config()
z = cfg.get(page_type, {}).get(zone_name)
if not isinstance(z, dict):
return None
try:
return (float(z["x1"]), float(z["y1"]), float(z["x2"]), float(z["y2"]))
except (KeyError, ValueError, TypeError):
return None
def _deep_copy(d: dict) -> dict:
return json.loads(json.dumps(d))

160
tests/test_checkboxes.py Normal file
View File

@@ -0,0 +1,160 @@
"""Tests unitaires pour pipeline.checkboxes."""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image
from pipeline.checkboxes import (
AMBIGU_MARGIN,
CheckboxZones,
RECUEIL_ACCORD_DESACCORD,
dark_ratio,
detect_accord_desaccord,
parse_ghs_injustifie,
)
# ============================================================
# parse_ghs_injustifie
# ============================================================
class TestParseGhsInjustifie:
@pytest.mark.parametrize("raw,expected", [
("0", "0"),
("1", "1"),
("0 SE 1 2 3 4 ATU FFM FSD", "0"),
("1 SE 2 ATU", "1"),
(" 0 ", "0"),
("", ""),
(None, ""),
("SE 1 2 3 4 ATU FFM FSD", ""), # pas de chiffre de tête
("abc", ""),
("2 SE 1", ""), # 2 n'est ni 0 ni 1
])
def test_cas_varies(self, raw, expected):
assert parse_ghs_injustifie(raw) == expected
# ============================================================
# dark_ratio (avec images synthétiques)
# ============================================================
def _solid_image(w: int, h: int, gray_value: int = 255) -> Image.Image:
arr = np.full((h, w), gray_value, dtype=np.uint8)
return Image.fromarray(arr, mode="L").convert("RGB")
def _image_with_dark_square(w: int, h: int,
square_bbox: tuple[float, float, float, float]) -> Image.Image:
"""Image blanche avec un carré noir dans la zone bbox (coords relatives)."""
arr = np.full((h, w), 255, dtype=np.uint8)
x1, y1, x2, y2 = square_bbox
arr[int(y1*h):int(y2*h), int(x1*w):int(x2*w)] = 0
return Image.fromarray(arr, mode="L").convert("RGB")
class TestDarkRatio:
def test_image_blanche(self):
img = _solid_image(100, 100, 255)
ratio = dark_ratio(img, (0.2, 0.2, 0.8, 0.8))
assert ratio == 0.0
def test_image_noire(self):
img = _solid_image(100, 100, 0)
ratio = dark_ratio(img, (0.2, 0.2, 0.8, 0.8))
assert ratio == 1.0
def test_inner_frac_ignore_les_bords(self):
"""Un carré noir occupe toute la zone mais avec un grand inner_frac
on ne voit que le centre, qui reste dans la zone noire."""
img = _image_with_dark_square(100, 100, (0.0, 0.0, 1.0, 1.0))
# Tout noir, peu importe inner_frac
assert dark_ratio(img, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35) == 1.0
def test_cadre_seul_vs_contenu_central(self):
"""Une case 'vide' (cadre seul) doit avoir un ratio inner_frac faible ;
une case 'cochée' (croix au centre) doit avoir un ratio plus élevé."""
# Simuler un cadre : carré noir sur le pourtour uniquement
w, h = 100, 100
arr = np.full((h, w), 255, dtype=np.uint8)
arr[:5, :] = 0; arr[-5:, :] = 0; arr[:, :5] = 0; arr[:, -5:] = 0
frame_only = Image.fromarray(arr, mode="L").convert("RGB")
# Cadre + croix au centre
arr2 = arr.copy()
# Une croix : 2 diagonales
for i in range(20, 80):
arr2[i, i] = 0
arr2[i, 100 - 1 - i] = 0
checked = Image.fromarray(arr2, mode="L").convert("RGB")
ratio_empty = dark_ratio(frame_only, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35)
ratio_full = dark_ratio(checked, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35)
# La case cochée doit avoir un ratio clairement plus élevé
assert ratio_full > ratio_empty + 0.05
# ============================================================
# detect_accord_desaccord (fixtures cache)
# ============================================================
class TestDetectAccordDesaccord:
"""Tests sur les images réelles du cache, avec ground truth vérifié
visuellement (cf. historique du projet, crops audités un par un).
Ground truth indexé par numéro d'OGC — le mapping vers le hash du cache
est résolu au runtime via pipeline.ingest.pdf_hash pour éviter de coder
les hashes en dur (fragile).
"""
# Ground truth vérifié visuellement sur les 18 dossiers 2018 CARC
GROUND_TRUTH_BY_OGC = {
1: "accord",
7: "accord",
9: "désaccord",
18: "désaccord",
20: "désaccord",
27: "désaccord",
29: "accord",
55: "accord",
66: "désaccord",
68: "accord",
69: "accord",
74: "désaccord",
76: "désaccord",
84: "accord",
86: "désaccord",
97: "accord",
99: "désaccord",
}
@pytest.fixture
def cached_pages_with_truth(self):
"""Résout le mapping numéro OGC → page_01.png disponible au runtime."""
from pathlib import Path
from pipeline.ingest import pdf_hash
pdf_dir = Path("2018 CARC")
if not pdf_dir.is_dir():
pytest.skip("répertoire 2018 CARC/ absent")
found = {}
for n, expected in self.GROUND_TRUTH_BY_OGC.items():
pdf = pdf_dir / f"OGC {n}.pdf"
if not pdf.exists():
continue
h = pdf_hash(str(pdf))
img = Path(f".cache/images/{h}/page_01.png")
if img.exists():
found[f"OGC {n}"] = (str(img), expected)
if not found:
pytest.skip("pas de cache d'images disponible — lance le pipeline d'abord")
return found
def test_ground_truth_echantillon(self, cached_pages_with_truth):
"""Sur les cas vérifiés visuellement, le détecteur doit matcher."""
errors = []
for name, (path, expected) in cached_pages_with_truth.items():
r = detect_accord_desaccord(path)
if r["decision"] != expected:
errors.append(f"{name}: attendu={expected}, got={r}")
assert not errors, "\n".join(errors)

140
tests/test_deskew.py Normal file
View File

@@ -0,0 +1,140 @@
"""Tests unitaires pour pipeline.deskew.
Tests sans dépendance GPU. Génère des images synthétiques en code + utilise
les images du cache pour les cas réels.
"""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from pipeline.deskew import (
MAX_ANGLE_DEG,
MIN_ANGLE_DEG,
NEAR_HORIZONTAL_BAND,
deskew_image,
detect_skew_angle,
)
# ============================================================
# Helpers : fabriquer une image synthétique avec des lignes
# ============================================================
def _make_grid_image(w: int = 800, h: int = 1000,
n_lines: int = 30, angle_deg: float = 0.0) -> Image.Image:
"""Crée une image blanche avec `n_lines` lignes horizontales équi-réparties,
optionnellement tournée d'un angle donné. Parfaite pour tester le détecteur.
"""
arr = np.ones((h, w), dtype=np.uint8) * 255
for i in range(1, n_lines + 1):
y = int(i * h / (n_lines + 1))
arr[y - 1:y + 1, 50:w - 50] = 0 # ligne horizontale noire de 2 px
img = Image.fromarray(arr, mode="L")
if angle_deg != 0.0:
# PIL.rotate : angle positif = sens trigonométrique (= anti-horaire)
# On veut tester avec notre convention (positif = horaire) donc
# on inverse ici pour cohérence avec detect_skew_angle
img = img.rotate(-angle_deg, resample=Image.Resampling.BICUBIC,
expand=False, fillcolor="white")
return img.convert("RGB")
# ============================================================
# Tests de détection
# ============================================================
class TestDetectSkewAngle:
def test_image_parfaitement_droite(self):
img = _make_grid_image()
angle = detect_skew_angle(img)
assert abs(angle) < 0.1, f"image droite doit donner ~0°, got {angle}"
@pytest.mark.parametrize("input_angle", [1.0, 2.0, -3.0, 4.0])
def test_detecte_angles_modérés(self, input_angle):
"""Sur notre image synthétique (30 lignes), la sensibilité est ~1°.
Sur de vraies fiches OGC avec 300+ lignes de tableaux, la sensibilité
descend à 0.3° (cf. test réel sur OGC 1 : +0.91° détecté).
"""
img = _make_grid_image(angle_deg=input_angle)
detected = detect_skew_angle(img)
assert abs(detected - input_angle) < 0.5, \
f"attendu ~{input_angle}°, détecté {detected}°"
def test_image_sans_lignes_retourne_zero(self):
# Image totalement uniforme → aucune ligne détectable
arr = np.ones((500, 500), dtype=np.uint8) * 255
img = Image.fromarray(arr, mode="L").convert("RGB")
assert detect_skew_angle(img) == 0.0
def test_angle_extrême_rejeté(self):
# Une rotation de 45° dépasse MAX_ANGLE_DEG → on refuse de corriger
img = _make_grid_image(angle_deg=45.0)
detected = detect_skew_angle(img)
# Soit 0.0 (pas de lignes quasi-horizontales à ±15°), soit borné
assert abs(detected) < MAX_ANGLE_DEG or detected == 0.0
# ============================================================
# Tests de correction (deskew_image)
# ============================================================
class TestDeskewImage:
def test_image_droite_inchangée(self):
img = _make_grid_image()
rotated, applied = deskew_image(img)
assert applied == 0.0
# Identité bit à bit
assert np.array_equal(np.array(rotated), np.array(img))
def test_image_inclinée_corrigée(self):
img = _make_grid_image(angle_deg=2.0)
rotated, applied = deskew_image(img)
# On attend qu'on applique un angle proche de 2° (convention positive)
assert abs(applied) > MIN_ANGLE_DEG, \
f"devrait corriger, got applied={applied}"
# Après rotation, l'angle résiduel doit être très faible
residual = detect_skew_angle(rotated)
assert abs(residual) < 0.5, \
f"angle résiduel trop grand après correction : {residual}°"
def test_seuil_min_angle_respecté(self):
# Un skew juste sous le seuil ne doit pas être corrigé
img = _make_grid_image(angle_deg=MIN_ANGLE_DEG / 2)
_, applied = deskew_image(img)
assert applied == 0.0
def test_angle_forcé(self):
"""On peut forcer un angle arbitraire indépendamment de la détection."""
img = _make_grid_image() # droit
rotated, applied = deskew_image(img, angle=5.0)
assert applied == 5.0
# Taille conservée
assert rotated.size == img.size
# ============================================================
# Tests avec fixtures réelles (si cache dispo)
# ============================================================
class TestOnRealCachedPages:
"""Ces tests s'exécutent seulement si le cache d'images existe."""
@pytest.fixture
def cached_pages(self):
paths = sorted(Path(".cache/images").glob("*/page_01.png"))
if not paths:
pytest.skip("pas de cache d'images disponible")
return paths
def test_detection_ne_crash_pas(self, cached_pages):
"""Sur toutes les pages cachées, detect_skew_angle ne doit pas planter."""
for p in cached_pages[:5]: # limite pour la vitesse
img = Image.open(p)
angle = detect_skew_angle(img)
assert isinstance(angle, float)
assert abs(angle) <= MAX_ANGLE_DEG

119
tests/test_json_utils.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests unitaires pour pipeline.json_utils."""
from __future__ import annotations
from pipeline.json_utils import (
close_open_json,
parse_json_output,
patch_missing_commas,
strip_fences,
truncate_empty_loop,
)
class TestStripFences:
def test_fence_json(self):
raw = '```json\n{"a": 1}\n```'
assert strip_fences(raw).strip() == '{"a": 1}'
def test_fence_simple(self):
raw = '```\n{"a": 1}\n```'
assert strip_fences(raw).strip() == '{"a": 1}'
def test_pas_de_fence(self):
raw = '{"a": 1}'
assert strip_fences(raw).strip() == '{"a": 1}'
class TestPatchMissingCommas:
def test_objets_consecutifs(self):
raw = '[\n{"a": 1}\n{"b": 2}\n]'
patched = patch_missing_commas(raw)
assert '},' in patched
def test_deja_correct(self):
raw = '{"a": 1}'
assert patch_missing_commas(raw) == raw
class TestTruncateEmptyLoop:
def test_moins_que_seuil(self):
raw = '[{"code":"","position":""},{"code":"","position":""}]'
# 2 objets vides = seuil par défaut, rien à tronquer
out = truncate_empty_loop(raw, max_consecutive=2)
assert out == raw
def test_boucle_tronquée(self):
objs = ['{"code":"","position":""}'] * 10
raw = '[' + ','.join(objs)
out = truncate_empty_loop(raw, max_consecutive=2)
# Après troncature, ne doit contenir que 2 occurrences
assert out.count('{"code":""') == 2
def test_pas_de_boucle(self):
raw = '[{"code":"K650","position":"1"}]'
assert truncate_empty_loop(raw) == raw
class TestCloseOpenJson:
def test_deja_ferme(self):
raw = '{"a": [1, 2]}'
assert close_open_json(raw) == raw
def test_accolade_manquante(self):
raw = '{"a": 1'
closed = close_open_json(raw)
assert closed == '{"a": 1}'
def test_crochet_manquant(self):
raw = '{"a": [1, 2'
closed = close_open_json(raw)
assert closed == '{"a": [1, 2]}'
def test_accolades_et_crochets_imbriqués(self):
raw = '{"a": {"b": [1, 2'
closed = close_open_json(raw)
assert closed == '{"a": {"b": [1, 2]}}'
def test_virgule_trainante_supprimée(self):
raw = '{"a": 1, '
closed = close_open_json(raw)
assert closed == '{"a": 1}'
def test_accolade_dans_string_ignorée(self):
raw = '{"a": "{ ceci est une { accolade dans une string"'
closed = close_open_json(raw)
# On ajoute juste l'accolade finale manquante
assert closed == raw + '}'
class TestParseJsonOutput:
def test_json_valide(self):
assert parse_json_output('{"a": 1}') == {"a": 1}
def test_vide(self):
assert parse_json_output("") is None
assert parse_json_output(None) is None
def test_fences_markdown(self):
assert parse_json_output('```json\n{"a": 1}\n```') == {"a": 1}
def test_virgule_manquante_recuperee(self):
raw = '[\n{"a": 1}\n{"b": 2}\n]'
result = parse_json_output(raw)
assert result == [{"a": 1}, {"b": 2}]
def test_boucle_tronquée_fermée(self):
objs = ['{"code":"","position":"","libelle":""}'] * 10
raw = '{"das": [\n' + ',\n'.join(objs) # non fermé
result = parse_json_output(raw)
assert isinstance(result, dict)
assert "das" in result
# Après troncature, 2 objets vides max, puis JSON refermé
assert result.get("_truncated_loop") is True
def test_fallback_retourne_raw(self):
"""Quand rien ne marche, on renvoie un dict avec _raw + _parse_error."""
raw = "ceci n'est pas du JSON du tout !"
result = parse_json_output(raw)
assert result.get("_raw") == raw
assert "_parse_error" in result

145
tests/test_recueil.py Normal file
View File

@@ -0,0 +1,145 @@
"""Tests unitaires pour pipeline.recueil (logique métier de la page recueil).
Les fonctions testées ici sont toutes pures (pas d'appel au VLM) :
- filter_cim10_codes
- classify_codes_dp_dr_das
- merge_codage_reco
- resolve_recueil_zones (juste lecture de config)
"""
from __future__ import annotations
from pipeline.recueil import (
classify_codes_dp_dr_das,
filter_cim10_codes,
merge_codage_reco,
resolve_recueil_zones,
)
class TestFilterCim10Codes:
def test_codes_valides_conservés(self):
codes = [
{"code": "K650", "position": "1"},
{"code": "T814", "position": "2"},
{"code": "Z954 *", "position": "3"},
]
out = filter_cim10_codes(codes)
assert len(out) == 3
assert out[0]["code"] == "K650"
def test_ccam_rejeté(self):
"""Un code CCAM (4 lettres + 3 chiffres) ne doit pas passer le filtre CIM-10."""
codes = [
{"code": "K650", "position": ""},
{"code": "EBFA012", "position": "1"}, # CCAM
]
out = filter_cim10_codes(codes)
assert len(out) == 1
assert out[0]["code"] == "K650"
def test_code_vide_rejeté(self):
codes = [{"code": "", "position": ""}, {"code": "K650", "position": ""}]
out = filter_cim10_codes(codes)
assert len(out) == 1
def test_non_dict_ignoré(self):
codes = ["K650", None, {"code": "T814", "position": ""}]
out = filter_cim10_codes(codes)
assert len(out) == 1
def test_liste_vide(self):
assert filter_cim10_codes([]) == []
assert filter_cim10_codes(None) == []
class TestClassifyCodesDpDrDas:
def test_cas_nominal(self):
"""1er sans position = DP, 2e sans position = DR, puis DAS avec positions."""
codes = [
{"code": "K650", "position": ""},
{"code": "T814", "position": ""},
{"code": "Z954", "position": "2"},
{"code": "R33", "position": "3"},
]
dp, dr, das = classify_codes_dp_dr_das(codes)
assert dp == "K650"
assert dr == "T814"
assert [d["code"] for d in das] == ["Z954", "R33"]
def test_dr_vide_non_duplique_dp(self):
"""Quand Qwen duplique le DP (parce que DR est visuellement vide),
on doit considérer que DR est vide, pas DR = DP."""
codes = [
{"code": "K650", "position": ""},
{"code": "K650", "position": ""}, # doublon
{"code": "T814", "position": "2"},
]
dp, dr, das = classify_codes_dp_dr_das(codes)
assert dp == "K650"
assert dr == "" # dédupliqué
assert len(das) == 1
def test_seulement_dp(self):
codes = [{"code": "K650", "position": ""}]
dp, dr, das = classify_codes_dp_dr_das(codes)
assert dp == "K650"
assert dr == ""
assert das == []
def test_tous_avec_positions(self):
"""Si tous les codes ont une position, DP et DR sont vides, tout en DAS."""
codes = [
{"code": "K650", "position": "1"},
{"code": "T814", "position": "2"},
]
dp, dr, das = classify_codes_dp_dr_das(codes)
assert dp == ""
assert dr == ""
assert len(das) == 2
def test_vide(self):
dp, dr, das = classify_codes_dp_dr_das([])
assert (dp, dr, das) == ("", "", [])
class TestMergeCodageReco:
def test_crop_prime_sur_passage_principal(self):
parsed = {"codage_reco": {"dp": "", "dr": "", "das": []}}
reco = {"dp": "K650", "dr": "T814",
"das": [{"code": "Z954", "position": "2"}]}
merge_codage_reco(parsed, reco)
assert parsed["codage_reco"]["dp"] == "K650"
assert parsed["codage_reco"]["dr"] == "T814"
assert len(parsed["codage_reco"]["das"]) == 1
def test_crop_vide_garde_passage_principal(self):
"""Si le crop a un champ vide mais le passage principal l'avait rempli,
on ne dégrade pas : on garde le passage principal."""
parsed = {"codage_reco": {"dp": "K650", "dr": "", "das": []}}
reco = {"dp": "", "dr": "", "das": []}
merge_codage_reco(parsed, reco)
assert parsed["codage_reco"]["dp"] == "K650" # préservé
def test_codage_reco_initialement_absent(self):
parsed = {}
reco = {"dp": "K650", "dr": "", "das": []}
merge_codage_reco(parsed, reco)
assert parsed["codage_reco"]["dp"] == "K650"
def test_trace_crop_ajoutee(self):
parsed = {"codage_reco": {"dp": "", "dr": "", "das": []}}
reco = {"dp": "K650", "_elapsed_s": 1.5}
merge_codage_reco(parsed, reco)
assert parsed["_crop_recodage"]["result"]["_elapsed_s"] == 1.5
class TestResolveRecueilZones:
def test_fallback_constantes(self):
"""Sans config utilisateur, on a les zones par défaut."""
reco, cb = resolve_recueil_zones()
# 4 coords flottantes
assert len(reco) == 4
assert all(isinstance(v, float) for v in reco)
# Checkbox zones
assert len(cb.accord) == 4
assert len(cb.desaccord) == 4

118
tests/test_schema.py Normal file
View File

@@ -0,0 +1,118 @@
"""Tests unitaires pour pipeline.schema (nettoyage JSON)."""
from __future__ import annotations
from pipeline.schema import (
CLEAN_FIELDS_RECUEIL,
DEBUG_FIELDS,
SCHEMA_VERSION,
clean_dossier,
)
def _sample_raw():
"""Un JSON pipeline type, riche en champs debug."""
return {
"fichier": "OGC 7",
"pdf_hash": "abc123",
"pages": [{"page": 1, "type": "recueil"}],
"extraction": {
"recueil": {
"etablissement": "CLINIQUE X",
"finess": "330780206",
"ghm_etab": "11M122",
"ghs_etab": "4323",
"codage_etab": {"dp": "K650"},
"accord_desaccord": "accord",
"_checkbox_debug": {"ratio_accord": 0.38, "ratio_desaccord": 0.19},
"_parse_error": "whatever",
"_truncated_loop": True,
"_crop_recodage": {"dp": "K650", "_source": "crop"},
"_validation": {
"summary": {"valid": 3, "invalid": 0, "empty": 2, "total_codes": 3},
"cross_checks": {
"etab": {"checked": True, "coherent": True},
"reco": {"checked": False, "reason": "ghm manquant"},
},
"codage_etab": {
"dp": {"code": "K650", "valid": True, "libelle_ref": "Péritonite"},
"dr": {"code": "", "valid": None},
"das": [],
},
"codage_reco": {"dp": {}, "dr": {}, "das": []},
"ghm_etab": {"code": "11M122", "valid": True,
"ghs_possibles": ["4323"]},
"ghs_etab": {"code": "4323", "valid": True},
"ghm_reco": {"code": "", "valid": None},
"ghs_reco": {"code": "", "valid": None},
},
},
"concertation_2": {
"ghs_initial": "4323",
"ghs_final": "4323",
"decision": "retour_groupage_dim",
"date_concertation": "13/03/2018",
},
},
"_meta": {"pipeline_version": "v2", "ocr_model": "Qwen/Qwen2.5-VL-3B-Instruct"},
}
class TestCleanDossier:
def test_retourne_schema_version(self):
out = clean_dossier(_sample_raw())
assert out["schema_version"] == SCHEMA_VERSION
def test_retire_tous_les_champs_debug(self):
"""Aucun champ de DEBUG_FIELDS ne doit rester dans la sortie clean."""
out = clean_dossier(_sample_raw())
rec = out["extraction"]["recueil"]
for debug_field in DEBUG_FIELDS:
assert debug_field not in rec, \
f"{debug_field} devrait être retiré"
def test_garde_les_champs_metier(self):
out = clean_dossier(_sample_raw())
rec = out["extraction"]["recueil"]
for f in ["etablissement", "finess", "ghm_etab", "ghs_etab",
"codage_etab", "accord_desaccord"]:
assert f in rec, f"{f} doit être présent dans clean"
def test_validation_compactee(self):
"""La validation est conservée mais en format compact."""
out = clean_dossier(_sample_raw())
v = out["extraction"]["recueil"]["_validation"]
# summary garde tel quel
assert v["summary"]["valid"] == 3
# cross_checks compactés : juste le coherent booléen (ou None)
assert v["cross_checks"] == {
"etab_ghm_ghs_coherent": True,
"reco_ghm_ghs_coherent": None,
}
# Les codes validés gardent libelle_ref quand dispo
assert v["codage_etab"]["dp"]["valid"] is True
assert v["codage_etab"]["dp"].get("libelle_ref") == "Péritonite"
def test_concertation_2_conservee(self):
out = clean_dossier(_sample_raw())
c2 = out["extraction"]["concertation_2"]
assert c2["ghs_initial"] == "4323"
assert c2["decision"] == "retour_groupage_dim"
def test_champs_inconnus_ignorés(self):
"""Un champ qui n'est pas dans CLEAN_FIELDS_RECUEIL est retiré."""
raw = _sample_raw()
raw["extraction"]["recueil"]["champ_inventé"] = "poubelle"
out = clean_dossier(raw)
assert "champ_inventé" not in out["extraction"]["recueil"]
def test_meta_preservee(self):
out = clean_dossier(_sample_raw())
assert out["_meta"]["pipeline_version"] == "v2"
assert "Qwen" in out["_meta"]["ocr_model"]
def test_pas_de_modification_input(self):
"""La fonction ne doit pas modifier l'input."""
raw = _sample_raw()
before = raw["extraction"]["recueil"].copy()
_ = clean_dossier(raw)
assert raw["extraction"]["recueil"] == before

146
tests/test_validation.py Normal file
View File

@@ -0,0 +1,146 @@
"""Tests unitaires pour pipeline.validation."""
from __future__ import annotations
import pytest
from pipeline.validation import (
_check_ccam,
_check_cim10,
_check_ghm,
_check_ghs,
_cross_check_ghm_ghs,
annotate,
validate_recueil,
)
# ============================================================
# Vérifications par type de code
# ============================================================
class TestCheckCim10:
def test_code_valide(self):
r = _check_cim10("K650")
assert r["valid"] is True
assert "libelle_ref" in r
def test_code_vide(self):
assert _check_cim10("")["valid"] is None
assert _check_cim10(None)["valid"] is None
def test_code_avec_suffixe_pmsi(self):
# Les suffixes * et +N sont gérés par la normalisation
r = _check_cim10("C795 *")
assert r["valid"] is True
def test_code_invalide_avec_suggestion(self):
# K65O (O au lieu de 0) n'existe pas, mais K650 oui
r = _check_cim10("K65O")
assert r["valid"] is False
assert r.get("suggestion") == "K650"
def test_code_invalide_sans_suggestion(self):
# Code farfelu sans voisin proche
r = _check_cim10("ZZZZ9999")
assert r["valid"] is False
# suggestion peut être absente
assert r.get("suggestion") is None or r.get("suggestion") != "ZZZZ9999"
class TestCheckGhm:
def test_ghm_valide(self):
r = _check_ghm("11M122")
assert r["valid"] is True
assert isinstance(r.get("ghs_possibles"), list)
assert len(r["ghs_possibles"]) > 0
def test_ghm_invalide(self):
r = _check_ghm("99Z999")
assert r["valid"] is False
class TestCheckGhs:
def test_ghs_valide(self):
assert _check_ghs("4323")["valid"] is True
def test_ghs_invalide(self):
assert _check_ghs("99999")["valid"] is False
class TestCheckCcam:
def test_ccam_valide(self):
assert _check_ccam("EBFA012")["valid"] is True
def test_ccam_invalide(self):
assert _check_ccam("XXXX000")["valid"] is False
# ============================================================
# Cross-checks GHM ↔ GHS
# ============================================================
class TestCrossCheckGhmGhs:
def test_couple_coherent(self):
# 11M122 a bien 4323 dans ses GHS possibles
r = _cross_check_ghm_ghs("11M122", "4323")
assert r["checked"] is True
assert r["coherent"] is True
def test_couple_incoherent(self):
# 11M122 ne correspond pas à n'importe quel GHS
r = _cross_check_ghm_ghs("11M122", "9999")
assert r["checked"] is True
assert r["coherent"] is False
def test_ghm_manquant(self):
r = _cross_check_ghm_ghs("", "4323")
assert r["checked"] is False
def test_ghm_invalide(self):
r = _cross_check_ghm_ghs("99Z999", "4323")
assert r["checked"] is False
assert "invalide" in r["reason"].lower()
# ============================================================
# annotate (intégration)
# ============================================================
class TestAnnotate:
def test_annotate_json_vide(self):
out = annotate({"fichier": "TEST", "extraction": {}})
assert "fichier" in out
assert out["extraction"] == {}
def test_annotate_recueil_complet(self):
raw = {
"fichier": "TEST",
"extraction": {
"recueil": {
"codage_etab": {"dp": "K650", "dr": "", "das": [
{"code": "T814", "position": "2"},
]},
"codage_reco": {"dp": "", "dr": "", "das": []},
"ghm_etab": "11M122",
"ghs_etab": "4323",
"ghm_reco": "",
"ghs_reco": "",
},
},
}
out = annotate(raw)
v = out["extraction"]["recueil"]["_validation"]
assert v["codage_etab"]["dp"]["valid"] is True
assert v["ghm_etab"]["valid"] is True
assert v["cross_checks"]["etab"]["coherent"] is True
assert v["summary"]["valid"] >= 3
def test_annotate_preserve_source(self):
"""L'annotation ne doit pas modifier l'input (copie défensive)."""
raw = {
"fichier": "T",
"extraction": {"recueil": {"codage_etab": {"dp": "K650"}}},
}
out = annotate(raw)
assert "_validation" not in raw["extraction"]["recueil"]
assert "_validation" in out["extraction"]["recueil"]

View File

@@ -0,0 +1,85 @@
"""Tests unitaires pour pipeline.zones_config."""
from __future__ import annotations
import json
import pytest
from pipeline.zones_config import (
DEFAULTS,
get_zone,
load_config,
save_config,
)
class TestLoadConfig:
def test_fichier_absent_retourne_defaults(self, tmp_path):
cfg = load_config(tmp_path / "inexistant.json")
assert cfg == DEFAULTS
def test_charge_depuis_fichier(self, tmp_path):
path = tmp_path / "zones.json"
custom = {
"recueil": {
"codage_reco": {"x1": 0.5, "y1": 0.1, "x2": 0.9, "y2": 0.4,
"description": "test"},
},
}
path.write_text(json.dumps(custom))
cfg = load_config(path)
assert cfg["recueil"]["codage_reco"]["x1"] == 0.5
def test_merge_avec_defaults(self, tmp_path):
"""Les zones non définies dans le fichier tombent en défaut."""
path = tmp_path / "zones.json"
partial = {
"recueil": {"codage_reco": {"x1": 0.1, "y1": 0.2, "x2": 0.3, "y2": 0.4}},
}
path.write_text(json.dumps(partial))
cfg = load_config(path)
# User override appliqué
assert cfg["recueil"]["codage_reco"]["x1"] == 0.1
# Default gardé pour l'autre zone
assert cfg["recueil"]["accord_checkbox"] == DEFAULTS["recueil"]["accord_checkbox"]
def test_json_corrompu_retombe_sur_defaults(self, tmp_path):
path = tmp_path / "corrupt.json"
path.write_text("{ not valid json [")
cfg = load_config(path)
assert cfg == DEFAULTS
class TestSaveConfig:
def test_save_puis_load_round_trip(self, tmp_path):
path = tmp_path / "zones.json"
original = {
"recueil": {
"codage_reco": {"x1": 0.11, "y1": 0.22, "x2": 0.33, "y2": 0.44,
"description": "abc"},
},
}
save_config(original, path)
reloaded = load_config(path)
assert reloaded["recueil"]["codage_reco"]["x1"] == 0.11
assert reloaded["recueil"]["codage_reco"]["description"] == "abc"
class TestGetZone:
def test_zone_existante(self):
z = get_zone("recueil", "codage_reco")
assert isinstance(z, tuple)
assert len(z) == 4
assert all(isinstance(v, float) for v in z)
def test_zone_inconnue_retourne_none(self):
assert get_zone("recueil", "zone_qui_nexiste_pas") is None
assert get_zone("page_fantaisiste", "whatever") is None
def test_config_explicite(self):
cfg = {
"recueil": {
"my_zone": {"x1": 0.0, "y1": 0.0, "x2": 1.0, "y2": 1.0},
},
}
assert get_zone("recueil", "my_zone", config=cfg) == (0.0, 0.0, 1.0, 1.0)