From ed4d9bd765f4bfa7638aa865e4e787682e6de14b Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 24 Apr 2026 15:05:40 +0200 Subject: [PATCH] feat(pipeline): extraction OGC via Qwen2.5-VL-3B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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) --- pipeline/__init__.py | 1 + pipeline/checkboxes.py | 87 +++++++++++++++++++ pipeline/classify.py | 70 +++++++++++++++ pipeline/cli.py | 53 ++++++++++++ pipeline/extract.py | 191 +++++++++++++++++++++++++++++++++++++++++ pipeline/ingest.py | 45 ++++++++++ pipeline/ocr_glm.py | 60 +++++++++++++ pipeline/ocr_qwen.py | 70 +++++++++++++++ pipeline/persist.py | 19 ++++ pipeline/prompts.py | 108 +++++++++++++++++++++++ 10 files changed, 704 insertions(+) create mode 100644 pipeline/__init__.py create mode 100644 pipeline/checkboxes.py create mode 100644 pipeline/classify.py create mode 100644 pipeline/cli.py create mode 100644 pipeline/extract.py create mode 100644 pipeline/ingest.py create mode 100644 pipeline/ocr_glm.py create mode 100644 pipeline/ocr_qwen.py create mode 100644 pipeline/persist.py create mode 100644 pipeline/prompts.py diff --git a/pipeline/__init__.py b/pipeline/__init__.py new file mode 100644 index 0000000..9c13cc5 --- /dev/null +++ b/pipeline/__init__.py @@ -0,0 +1 @@ +"""Pipeline d'extraction OGC basé sur GLM-OCR 0.9B.""" diff --git a/pipeline/checkboxes.py b/pipeline/checkboxes.py new file mode 100644 index 0000000..5e42eb8 --- /dev/null +++ b/pipeline/checkboxes.py @@ -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), + } diff --git a/pipeline/classify.py b/pipeline/classify.py new file mode 100644 index 0000000..a5a21ef --- /dev/null +++ b/pipeline/classify.py @@ -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"] diff --git a/pipeline/cli.py b/pipeline/cli.py new file mode 100644 index 0000000..80bf0c5 --- /dev/null +++ b/pipeline/cli.py @@ -0,0 +1,53 @@ +"""CLI : traite un PDF ou un répertoire de PDFs. + +Usage : + python -m pipeline.cli [--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()) diff --git a/pipeline/extract.py b/pipeline/extract.py new file mode 100644 index 0000000..d87e88d --- /dev/null +++ b/pipeline/extract.py @@ -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 diff --git a/pipeline/ingest.py b/pipeline/ingest.py new file mode 100644 index 0000000..8cd1433 --- /dev/null +++ b/pipeline/ingest.py @@ -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) diff --git a/pipeline/ocr_glm.py b/pipeline/ocr_glm.py new file mode 100644 index 0000000..468bc1e --- /dev/null +++ b/pipeline/ocr_glm.py @@ -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} diff --git a/pipeline/ocr_qwen.py b/pipeline/ocr_qwen.py new file mode 100644 index 0000000..cb4cdf5 --- /dev/null +++ b/pipeline/ocr_qwen.py @@ -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} diff --git a/pipeline/persist.py b/pipeline/persist.py new file mode 100644 index 0000000..c9904c3 --- /dev/null +++ b/pipeline/persist.py @@ -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 diff --git a/pipeline/prompts.py b/pipeline/prompts.py new file mode 100644 index 0000000..c807bec --- /dev/null +++ b/pipeline/prompts.py @@ -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}, +}