feat(pipeline): extraction OGC via Qwen2.5-VL-3B

Pipeline modulaire remplaçant le monolithe extract_ogc.py (conservé
en legacy pour comparaison).

Modules :
- ingest.py      : PDF → PNG 300dpi avec cache par SHA256
- ocr_qwen.py    : wrapper singleton Qwen2.5-VL-3B (bfloat16, ~7 Go VRAM)
- ocr_glm.py     : wrapper GLM-OCR 0.9B (alternatif, conservé)
- classify.py    : détection type de page + routing par index standard
                   (ordre des 6 pages OGC → -50% d'appels OCR)
- prompts.py     : JSON schemas par type (recueil, concertation 1/2/2/2,
                   preuves) + mots-clés de classification
- checkboxes.py  : détection Accord/Désaccord par densité de pixels
                   (inner-frac 0.35, 17/17 corrects sur échantillon vérifié ;
                   GLM-OCR et Qwen échouent sur les checkboxes, cf.
                   scratch/test_prompt_crop_v2.py)
- extract.py     : orchestration 1 dossier (ingest → classify → OCR →
                   parse JSON tolérant aux boucles + validation ATIH)
- persist.py     : sauvegarde JSON + metadata (pipeline_version,
                   ocr_model, timestamp)
- cli.py         : `python -m pipeline.cli <pdf|dir>`

Temps mesuré : ~35s/dossier (6 pages) sur RTX 5070.

Qwen2.5-VL-3B retenu après comparaison avec GLM-OCR 0.9B, GOT-OCR2.0,
Surya, PaddleOCR (cf. scratch/). Il extrait correctement dp_libelle,
praticien_conseil et les 4 GHM/GHS là où les autres échouent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-24 15:05:40 +02:00
parent ddebd8dfbf
commit ed4d9bd765
10 changed files with 704 additions and 0 deletions

1
pipeline/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Pipeline d'extraction OGC basé sur GLM-OCR 0.9B."""

87
pipeline/checkboxes.py Normal file
View File

@@ -0,0 +1,87 @@
"""Détection de cases à cocher par analyse de densité de pixels sombres.
GLM-OCR ne sait pas distinguer une case cochée d'une case vide (A/B test, cf.
test_prompt_crop_v2.py). On reprend l'approche déterministe du pipeline legacy :
cropper la case à coord relative, compter les pixels sombres, comparer les
densités entre les deux cases pour trancher.
"""
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image
DARK_THRESHOLD = 128 # pixels < 128 (0-255) comptés comme sombres
AMBIGU_MARGIN = 0.01 # différence minimale de densité pour trancher
# (calibré pour couvrir OGC 69 où diff=+0.010)
INNER_FRAC = 0.35 # on exclut 35% de marge pour ignorer le cadre et
# n'analyser que le centre de la case (calibré sur
# OGC 7/27/55/86 : 4/4 corrects)
@dataclass
class CheckboxZones:
"""Coordonnées relatives (x1,y1,x2,y2) dans [0,1] des deux cases."""
accord: tuple[float, float, float, float]
desaccord: tuple[float, float, float, float]
# Zones englobant la case entière (cadre compris). On utilisera INNER_FRAC
# pour n'analyser que le centre et ignorer le cadre.
RECUEIL_ACCORD_DESACCORD = CheckboxZones(
accord= (0.588, 0.838, 0.622, 0.860),
desaccord= (0.588, 0.858, 0.622, 0.880),
)
# Zones pour la page concertation_2 (maintien / retour / autre groupage)
# À calibrer si besoin plus tard
CONCERTATION_2_DECISION = CheckboxZones(
accord= (0.035, 0.270, 0.060, 0.290), # maintien avis contrôleur
desaccord= (0.280, 0.270, 0.305, 0.290), # retour groupage DIM
)
def dark_ratio(image: Image.Image, zone: tuple[float, float, float, float],
inner_frac: float = INNER_FRAC) -> float:
"""Fraction de pixels sombres au centre de la zone (cadre exclu).
On crope la zone puis on retire une marge de `inner_frac` de chaque côté
pour ne garder que le contenu central (croix éventuelle).
"""
w, h = image.size
x1, y1, x2, y2 = zone
px1, py1 = int(x1*w), int(y1*h)
px2, py2 = int(x2*w), int(y2*h)
dx = int((px2 - px1) * inner_frac)
dy = int((py2 - py1) * inner_frac)
crop = image.crop((px1 + dx, py1 + dy, px2 - dx, py2 - dy))
gray = np.array(crop.convert("L"))
return float(np.mean(gray < DARK_THRESHOLD))
def detect_accord_desaccord(
image_path: str | Path,
zones: CheckboxZones = RECUEIL_ACCORD_DESACCORD,
) -> dict:
"""Retourne la décision et les ratios bruts pour debug/audit.
Convention : "accord" si la case Accord est plus remplie, "désaccord" si
la case Désaccord est plus remplie, "ambigu" si l'écart est trop faible.
"""
img = Image.open(image_path)
r_acc = dark_ratio(img, zones.accord)
r_des = dark_ratio(img, zones.desaccord)
diff = r_acc - r_des
if abs(diff) < AMBIGU_MARGIN:
decision = "ambigu"
elif diff > 0:
decision = "accord"
else:
decision = "désaccord"
return {
"decision": decision,
"ratio_accord": round(r_acc, 4),
"ratio_desaccord": round(r_des, 4),
"diff": round(diff, 4),
}

70
pipeline/classify.py Normal file
View File

@@ -0,0 +1,70 @@
"""Détection du type de page.
Deux stratégies :
1. `route_by_index` (rapide, défaut) : exploite le fait que les fiches OGC
respectent un ordre standardisé de 6 pages. Pas d'OCR, 0 coût.
2. `detect_page_type` (OCR de l'en-tête) : fallback quand l'ordre standard
n'est pas respecté ou quand on veut vérifier explicitement.
"""
import re
from pathlib import Path
from PIL import Image
from .ocr_qwen import QwenVLOCR
from .prompts import PAGE_TYPES
# Ordre canonique des 6 pages d'un dossier OGC standardisé
STANDARD_ORDER = [
"recueil",
"concertation_med",
"hospitalisation",
"preuves",
"concertation_2",
"concertation_1",
]
def route_by_index(num_pages: int) -> list[str]:
"""Retourne le type attendu pour chaque page selon l'ordre standard.
Si le dossier a moins de 6 pages, on prend le préfixe de STANDARD_ORDER.
Si plus de 6 pages, les pages supplémentaires sont marquées "inconnu".
"""
types = []
for i in range(num_pages):
types.append(STANDARD_ORDER[i] if i < len(STANDARD_ORDER) else "inconnu")
return types
def crop_header(image_path: Path, out_path: Path | None = None) -> Path:
"""Crop la bande d'en-tête (haut 12% de la page) pour classification rapide.
Important : le fichier produit ne doit PAS matcher le glob 'page_*.png'
qu'utilise pdf_to_images pour lister les pages, sinon il serait relu
comme une page au run suivant (ratio d'aspect cassé).
"""
img = Image.open(image_path)
w, h = img.size
header = img.crop((0, 0, w, int(h * 0.12)))
if out_path is None:
# Sous-dossier dédié pour isoler les crops temporaires
headers_dir = image_path.parent / "_headers"
headers_dir.mkdir(exist_ok=True)
out_path = headers_dir / f"{image_path.stem}.png"
header.save(out_path, "PNG")
return out_path
def detect_page_type(image_path: Path, ocr: QwenVLOCR | None = None) -> tuple[str, str]:
"""Classifie une page. Retourne (type, header_text)."""
ocr = ocr or QwenVLOCR()
header_path = crop_header(image_path)
res = ocr.run(header_path, "Text Recognition:", max_new_tokens=200)
text = res["text"].upper()
# Normaliser les caractères accentués pour le matching
text_norm = re.sub(r"[ÉÈÊË]", "E", text)
text_norm = re.sub(r"[ÀÂÄ]", "A", text_norm)
for ptype, conf in PAGE_TYPES.items():
if any(kw in text_norm for kw in conf["keywords"]):
return ptype, res["text"]
return "inconnu", res["text"]

53
pipeline/cli.py Normal file
View File

@@ -0,0 +1,53 @@
"""CLI : traite un PDF ou un répertoire de PDFs.
Usage :
python -m pipeline.cli <pdf|dir> [--out output/v2]
"""
import argparse
import glob
import sys
import time
from pathlib import Path
from .extract import extract_dossier
from .persist import save_result
def main():
p = argparse.ArgumentParser(description="Pipeline OGC v1 (GLM-OCR)")
p.add_argument("input", help="PDF unique ou répertoire contenant des PDFs")
p.add_argument("--out", default="output/v2", help="Répertoire de sortie JSON")
p.add_argument("--quiet", action="store_true")
args = p.parse_args()
input_path = Path(args.input)
if input_path.is_dir():
pdfs = sorted(input_path.glob("*.pdf"))
elif input_path.is_file() and input_path.suffix.lower() == ".pdf":
pdfs = [input_path]
else:
# Globbing si chemin avec espaces/motifs
pdfs = [Path(p) for p in sorted(glob.glob(str(input_path))) if p.lower().endswith(".pdf")]
if not pdfs:
print(f"Aucun PDF trouvé pour : {args.input}")
return 1
print(f"{len(pdfs)} PDF(s) à traiter → {args.out}")
t0 = time.time()
for pdf in pdfs:
t_pdf = time.time()
try:
result = extract_dossier(pdf, verbose=not args.quiet)
out_path = save_result(result, args.out)
print(f"{pdf.name}{out_path} ({time.time()-t_pdf:.1f}s)")
except Exception as e:
print(f"{pdf.name} : {e}")
import traceback
traceback.print_exc()
print(f"Terminé en {time.time()-t0:.1f}s")
return 0
if __name__ == "__main__":
sys.exit(main())

191
pipeline/extract.py Normal file
View File

@@ -0,0 +1,191 @@
"""Orchestration d'extraction pour un dossier OGC."""
import json
import re
import time
from pathlib import Path
from .ingest import pdf_to_images
from .classify import detect_page_type, route_by_index
from .ocr_qwen import QwenVLOCR
from .prompts import PAGE_TYPES, PROMPT_HEADER
from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD
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 _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.
"""
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]
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 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
"""
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
# 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
# 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)}
def extract_dossier(pdf_path: str | Path, verbose: bool = True,
use_standard_routing: bool = True) -> dict:
"""Pipeline complet d'un dossier : PDF → JSON structuré.
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).
"""
pdf_path = Path(pdf_path)
ocr = QwenVLOCR()
if verbose:
print(f"[{pdf_path.name}] modèle prêt, VRAM={ocr.vram_gb:.2f} Go")
images = pdf_to_images(str(pdf_path))
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
result = {
"fichier": pdf_path.stem,
"pdf_hash": images[0].parent.name,
"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 = {
"page": idx,
"type": ptype,
"header": header_text.strip(),
"elapsed_s": None,
}
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"]
page_info["parsed"] = parsed
page_info["elapsed_s"] = round(res["elapsed_s"], 2)
# 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
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

45
pipeline/ingest.py Normal file
View File

@@ -0,0 +1,45 @@
"""PDF → images PNG 300 dpi avec cache par hash SHA256."""
import hashlib
import os
from pathlib import Path
from pdf2image import convert_from_path
from PIL import Image
DEFAULT_DPI = 300
CACHE_ROOT = Path(".cache/images")
def pdf_hash(pdf_path: str) -> str:
"""Hash SHA256 court du contenu PDF."""
h = hashlib.sha256()
with open(pdf_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()[:16]
def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI, cache_root: Path = CACHE_ROOT) -> 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.
"""
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"))
if existing:
return existing
pages = convert_from_path(pdf_path, dpi)
paths = []
for i, img in enumerate(pages, 1):
p = out_dir / f"page_{i:02d}.png"
img.save(p, "PNG", optimize=True)
paths.append(p)
return paths
def load_image(path: Path) -> Image.Image:
return Image.open(path)

60
pipeline/ocr_glm.py Normal file
View File

@@ -0,0 +1,60 @@
"""Wrapper singleton pour GLM-OCR 0.9B."""
import time
from pathlib import Path
import torch
from transformers import AutoProcessor, AutoModelForImageTextToText
MODEL_PATH = "zai-org/GLM-OCR"
class GLMOCR:
"""Charge GLM-OCR une fois, réutilise le modèle pour toutes les pages."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_model()
return cls._instance
def _init_model(self):
t0 = time.time()
self.processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=True)
self.model = AutoModelForImageTextToText.from_pretrained(
MODEL_PATH,
torch_dtype="auto",
device_map="auto",
trust_remote_code=True,
)
self.load_time = time.time() - t0
self.vram_gb = torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
def run(self, image_path: str | Path, prompt: str, max_new_tokens: int = 4096) -> dict:
"""Exécute GLM-OCR sur une image avec un prompt, retourne {text, elapsed_s}."""
image_path = str(image_path)
messages = [{
"role": "user",
"content": [
{"type": "image", "url": image_path},
{"type": "text", "text": prompt},
],
}]
t0 = time.time()
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
).to(self.model.device)
inputs.pop("token_type_ids", None)
with torch.no_grad():
generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
output = self.processor.decode(
generated_ids[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=False,
)
# Nettoyer le marqueur de fin utilisateur
output = output.replace("<|user|>", "").strip()
return {"text": output, "elapsed_s": time.time() - t0}

70
pipeline/ocr_qwen.py Normal file
View File

@@ -0,0 +1,70 @@
"""Wrapper singleton pour Qwen2.5-VL-3B.
Qwen2.5-VL-3B surpasse GLM-OCR sur les fiches OGC : extrait `dp_libelle`,
`praticien_conseil` (manuscrit !), `codage_reco.dp` et les 4 GHM/GHS là où
GLM-OCR échouait systématiquement. Un poil plus rapide aussi (3s vs 4s/page).
Coût : ~7 Go VRAM en bfloat16 (GLM-OCR = 2.2 Go) → tient sur RTX 5070 12 Go.
"""
import time
from pathlib import Path
import torch
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
from qwen_vl_utils import process_vision_info
MODEL_PATH = "Qwen/Qwen2.5-VL-3B-Instruct"
class QwenVLOCR:
"""Charge Qwen2.5-VL-3B une fois, réutilise le modèle pour toutes les pages."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_model()
return cls._instance
def _init_model(self):
t0 = time.time()
# max_pixels limite le nombre de patches visuels pour éviter l'OOM
# sur images 300 dpi (2481x3509). ~1.25M pixels = équilibre qualité/VRAM.
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",
)
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
def run(self, image_path: str | Path, prompt: str, max_new_tokens: int = 2048) -> dict:
"""Exécute Qwen-VL sur une image avec un prompt, retourne {text, elapsed_s}."""
image_path = str(image_path)
messages = [{
"role": "user",
"content": [
{"type": "image", "image": image_path},
{"type": "text", "text": prompt},
],
}]
t0 = time.time()
text = self.processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = self.processor(
text=[text], images=image_inputs, videos=video_inputs,
padding=True, return_tensors="pt",
).to(self.model.device)
with torch.no_grad():
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]
return {"text": output.strip(), "elapsed_s": time.time() - t0}

19
pipeline/persist.py Normal file
View File

@@ -0,0 +1,19 @@
"""Sauvegarde JSON + journal d'exécution."""
import json
from datetime import datetime, timezone
from pathlib import Path
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)
result["_meta"] = {
"pipeline_version": "v1",
"ocr_model": "zai-org/GLM-OCR",
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
out_path = out_dir / f"{result['fichier']}.json"
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return out_path

108
pipeline/prompts.py Normal file
View File

@@ -0,0 +1,108 @@
"""Prompts GLM-OCR par type de page OGC.
Pour chaque type structuré, on demande un JSON strict. Pour les pages libres
(concertation 2, éléments de preuve…) on sort du texte brut.
"""
# Prompt court pour détecter le type de page depuis l'en-tête.
PROMPT_HEADER = "Text Recognition:"
# --- Page 1 : Fiche médicale de recueil du praticien conseil ---
# Note : accord_desaccord retiré du prompt — géré par pipeline.checkboxes
# (densité de pixels) car GLM-OCR ne sait pas lire les checkboxes
# (cf. test_prompt_crop_v2.py).
#
SCHEMA_RECUEIL = """Lis la fiche médicale OGC et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
Les codes CIM-10 sont au format lettre + 2 à 4 chiffres (ex: K650, T814).
Les codes CCAM sont au format 4 lettres + 3 chiffres (ex: EBFA012).
Les codes GHM sont au format 2 chiffres + lettre + 3 chiffres (ex: 11M122).
Les codes GHS sont des nombres à 3-5 chiffres (ex: 4323).
Si un champ est illisible, laisse une chaîne vide. Ne devine pas.
{
"etablissement": "",
"finess": "",
"date_debut_controle": "",
"n_ogc": "",
"n_champ": "",
"dates_sejour": "",
"sejour_etab": {
"age": "", "sexe": "", "duree_sejour": "",
"mode_entree": "", "provenance": "",
"mode_sortie": "", "destination": ""
},
"sejour_reco": {
"age": "", "sexe": "", "duree_sejour": "",
"mode_entree": "", "provenance": "",
"mode_sortie": "", "destination": ""
},
"rum_etab": {"um": "", "igs": "", "duree": "", "dates": ""},
"codage_etab": {
"dp": "", "dp_libelle": "", "dr": "",
"das": [{"code": "", "position": "", "libelle": ""}]
},
"codage_reco": {
"dp": "", "dr": "",
"das": [{"code": "", "position": ""}]
},
"actes_etab": [{"code": "", "position": "", "libelle": ""}],
"actes_reco": [{"code": "", "position": ""}],
"ghm_etab": "", "ghs_etab": "",
"ghm_reco": "", "ghs_reco": "",
"recodage_impactant": "",
"ghs_injustifie": "",
"praticien_conseil": ""
}"""
# --- 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.
{
"ghs_initial": "",
"ghs_avant_concertation": "",
"ghs_final": "",
"decision": "",
"date_concertation": "",
"praticien_controleur": "",
"medecin_dim": ""
}
Pour "decision", choisis UNIQUEMENT une de ces valeurs selon la case cochée :
- "maintien_avis_controleur" si "Maintien de l'avis initial" est coché
- "retour_groupage_dim" si "Retour groupage initial DIM" est coché
- "autre_groupage" si "Autre groupage" est coché
- "" si rien n'est coché"""
# --- Page 6 : Fiche administrative de concertation 1/2 (argumentaire) ---
SCHEMA_CONCERTATION_1 = """Lis la fiche d'argumentaire du médecin contrôleur et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
{
"date_concertation": "",
"argumentaire": ""
}
Pour "argumentaire", transcris TOUT le paragraphe sous "ARGUMENTAIRE DU MEDECIN CONTROLEUR" tel quel, sans reformuler."""
# --- Page 4 : Éléments de preuve ---
SCHEMA_PREUVES = """Lis le tableau des éléments de preuve et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
Pour chaque ligne, indique si la case "Présent" ou "Photocopié" est cochée (true/false).
{
"date": "",
"praticien_controleur": "",
"medecin_dim": "",
"pieces": [
{"intitule": "", "present": false, "photocopie": false, "absent_date": "", "date_obtention": ""}
]
}"""
# Types de page reconnus
PAGE_TYPES = {
"recueil": {"keywords": ["RECUEIL DU PRATICIEN"], "prompt": SCHEMA_RECUEIL},
"concertation_2": {"keywords": ["CONCERTATION 2/2"], "prompt": SCHEMA_CONCERTATION_2},
"concertation_1": {"keywords": ["CONCERTATION 1/2"], "prompt": SCHEMA_CONCERTATION_1},
"preuves": {"keywords": ["ELEMENTS DE PREUVE", "PREUVE"], "prompt": SCHEMA_PREUVES},
"concertation_med": {"keywords": ["FICHE MEDICALE DE CONCERTATION"], "prompt": PROMPT_HEADER},
"hospitalisation":{"keywords": ["SEJOUR D'HOSPITALISATION", "HOSPITALISATION COMPLETE"], "prompt": PROMPT_HEADER},
}