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:
136
pipeline/json_utils.py
Normal file
136
pipeline/json_utils.py
Normal 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)}
|
||||
Reference in New Issue
Block a user