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