"""Orchestration d'extraction pour un dossier OGC.""" import json import re import time from pathlib import Path from PIL import Image 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, SCHEMA_RECUEIL_RECODAGE, RECUEIL_RECODAGE_ZONE, ) from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD, parse_ghs_injustifie 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_recodage_crop(image_path: Path, ocr: QwenVLOCR) -> dict | None: """Second passage VLM sur le crop zonal de la colonne Recodage. Qwen nous renvoie la liste brute de tous les codes visibles (avec position si présente). On classifie DP/DR/DAS en Python par règles : - les 1ᵉʳ et 2ᵉ codes SANS position → DP puis DR (DR peut être vide si le 2ᵉ code a déjà une position). - tous les codes AVEC position → DAS. Retourne un dict {dp, dr, das[]} ou None en cas d'échec. """ try: img = Image.open(image_path) w, h = img.size x1, y1, x2, y2 = RECUEIL_RECODAGE_ZONE 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 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 # Filtrer : ne garder que les codes au format CIM-10. Si le crop dépasse # malgré tout dans la zone Actes, les CCAM (4 lettres + 3 chiffres) seront # exclus ici. cim10_re = re.compile(r"^[A-Z]\d{2,4}\s*\*?\s*\+?\d*$") codes_raw = parsed.get("codes") or [] codes = [] for c in codes_raw: if not isinstance(c, dict): continue code = (c.get("code") or "").strip() if code and cim10_re.match(code): codes.append({ "code": code, "position": str(c.get("position") or "").strip(), }) # Classifier par règle métier : # - 1er code sans position → DP # - 2e code sans position → DR (sauf s'il est identique au DP : Qwen tend # à dupliquer le DP quand DR est vide — on préfère DR="") # - codes avec position → DAS dp, dr = "", "" das = [] 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: # doublon du DP → on considère que DR est vide dr_assigned = True else: dr, dr_assigned = code, True else: das.append({"code": code, "position": ""}) else: das.append(c) return { "dp": dp, "dr": dr, "das": das, "_source": "crop_recodage", "_elapsed_s": round(res["elapsed_s"], 2), "_n_codes_raw": len(codes_raw), "_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 : le crop est plus fiable (contexte isolé), il prime sur le passage principal SAUF si le crop laisse vide un champ que le principal avait bien lu. """ existing = parsed.get("codage_reco") if isinstance(parsed.get("codage_reco"), dict) else {} merged = { "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["codage_reco"] = merged parsed.setdefault("_crop_recodage", {})["result"] = reco 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 + normalisation champs booléens # sur la fiche recueil. GLM-OCR / Qwen ne lisent pas les cases # à cocher (cf. scratch/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 # ghs_injustifie : Qwen renvoie parfois "0 SE 1 2 3 4 ATU FFM FSD" # → ne garder que le chiffre 0/1 de tête parsed["ghs_injustifie"] = parse_ghs_injustifie(parsed.get("ghs_injustifie", "")) # Second passage : crop de la colonne Recodage pour compenser # la sous-extraction observée sur codage_reco.* en passage principal. reco = _extract_recodage_crop(img_path, ocr) if reco: _merge_codage_reco(parsed, reco) 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