Files
Aivanov_scan_ogc/pipeline/extract.py
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

133 lines
5.0 KiB
Python

"""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 .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 .recueil import enrich_recueil, resolve_recueil_zones
from .validation import annotate as validate_annotate
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.
Retourne (parsed_dict_ou_None, ocr_raw, elapsed_s). `parsed=None` quand
la page n'a pas de prompt structuré associé (concertation_med, hospit.).
"""
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 _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.
Retourne (page_types, headers). `headers[i]` est vide si pas de classify
effectuée sur la page i.
"""
page_types: list[str | None] = [None] * len(images)
headers: list[str] = [""] * len(images)
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")
# 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é + annoté ATIH.
É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()
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")
page_types, headers = _resolve_routing(images, ocr, use_standard_routing, verbose)
_, cb_zones = resolve_recueil_zones()
result: dict = {
"fichier": pdf_path.stem,
"pdf_hash": images[0].parent.name if images else "",
"pages": [],
"extraction": {},
}
for idx, img_path in enumerate(images, 1):
t0 = time.time()
ptype = page_types[idx - 1]
header_text = headers[idx - 1]
page_info: dict = {
"page": idx,
"type": ptype,
"header": header_text.strip(),
"elapsed_s": None,
}
if verbose:
print(f" p{idx}: {ptype}")
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"] = elapsed
if ptype == "recueil" and isinstance(parsed, dict):
enrich_recueil(parsed, img_path, ocr, cb_zones)
page_info["parsed"] = parsed
result["extraction"][ptype] = parsed
else:
page_info["elapsed_s"] = round(time.time() - t0, 2)
result["pages"].append(page_info)
return validate_annotate(result)