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>
This commit is contained in:
Dom
2026-04-24 23:29:03 +02:00
parent 1255468676
commit d326524e49
3 changed files with 415 additions and 260 deletions

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)}