Spaces:
Build error
Build error
| """ | |
| Parsing et validation de la réponse brute de l'IA → layout dict + OCRResult. | |
| Comportement : | |
| - JSON non parseable → ParseError (toute la page échoue) | |
| - Région avec bbox invalide → région ignorée + log (la page continue) | |
| - OCR invalide → OCRResult() par défaut + log (la page continue) | |
| Le parser est tolérant : il extrait le premier objet JSON valide du texte | |
| même si l'IA ajoute du texte autour, des balises Markdown, des virgules | |
| en trop, ou des échappements Unicode cassés. | |
| """ | |
| # 1. stdlib | |
| import json | |
| import logging | |
| import re | |
| # 2. third-party | |
| from pydantic import ValidationError | |
| # 3. local | |
| from app.schemas.page_master import OCRResult, Region | |
| logger = logging.getLogger(__name__) | |
| class ParseError(Exception): | |
| """Levée si la réponse de l'IA est un JSON invalide ou structurellement incorrecte.""" | |
| def _extract_json_object(text: str) -> str: | |
| """Extrait le premier objet JSON { ... } complet du texte. | |
| Les VLMs renvoient souvent du texte avant/après le JSON, ou plusieurs | |
| blocs JSON concaténés. Cette fonction trouve le premier '{' et compte | |
| les accolades pour trouver le '}' fermant correspondant, en ignorant | |
| les accolades à l'intérieur des chaînes. | |
| """ | |
| start = text.find("{") | |
| if start == -1: | |
| return text | |
| depth = 0 | |
| in_string = False | |
| escape = False | |
| for i in range(start, len(text)): | |
| c = text[i] | |
| if escape: | |
| escape = False | |
| continue | |
| if c == "\\": | |
| escape = True | |
| continue | |
| if c == '"' and not escape: | |
| in_string = not in_string | |
| continue | |
| if in_string: | |
| continue | |
| if c == "{": | |
| depth += 1 | |
| elif c == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| return text[start : i + 1] | |
| # Pas de fermeture trouvée — retourner depuis le premier '{' | |
| return text[start:] | |
| def _fix_common_json_issues(text: str) -> str: | |
| """Corrige les erreurs JSON courantes des VLMs. | |
| - Trailing commas avant } ou ] | |
| - Échappements Unicode invalides (\\uXXXX incomplets) | |
| """ | |
| # Trailing commas : ,} ou ,] | |
| text = re.sub(r",\s*([}\]])", r"\1", text) | |
| # Échappements unicode invalides — remplacer par un espace | |
| text = re.sub(r"\\u(?![0-9a-fA-F]{4})[^\"]*", " ", text) | |
| return text | |
| def _try_parse_json(text: str) -> dict | None: | |
| """Tente de parser du JSON, avec nettoyage progressif en cas d'échec.""" | |
| # Tentative 1 : tel quel | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| # Tentative 2 : corrections courantes | |
| fixed = _fix_common_json_issues(text) | |
| try: | |
| return json.loads(fixed) | |
| except json.JSONDecodeError: | |
| pass | |
| return None | |
| def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]: | |
| """Parse la réponse textuelle de l'IA en layout dict + OCRResult validés. | |
| Les régions avec bbox invalide sont ignorées individuellement (loguées) sans | |
| faire échouer toute la page. Un JSON non parseable lève ParseError. | |
| Gère les balises Markdown (```json ... ```) que certains modèles ajoutent | |
| malgré les instructions, ainsi que le texte avant/après le JSON et les | |
| erreurs de formatage courantes des VLMs. | |
| Args: | |
| raw_text: texte brut retourné par l'IA (censé être du JSON strict). | |
| Returns: | |
| Tuple (layout_dict, ocr_result) où layout_dict = {"regions": [...]}. | |
| Raises: | |
| ParseError: si le texte n'est pas du JSON valide ou pas un objet JSON. | |
| """ | |
| # Suppression des balises Markdown éventuelles | |
| text = raw_text.strip() | |
| if text.startswith("```"): | |
| lines = text.splitlines() | |
| end = len(lines) - 1 if lines[-1].strip() == "```" else len(lines) | |
| text = "\n".join(lines[1:end]) | |
| # Extraction du premier objet JSON (ignore le texte autour) | |
| text = _extract_json_object(text) | |
| data = _try_parse_json(text) | |
| if data is None: | |
| raise ParseError( | |
| f"Réponse IA non parseable en JSON après nettoyage. " | |
| f"Début du texte brut : {raw_text[:300]!r}" | |
| ) | |
| if not isinstance(data, dict): | |
| raise ParseError( | |
| f"Réponse IA invalide — objet JSON attendu, reçu : {type(data).__name__}" | |
| ) | |
| # ── Layout / régions ──────────────────────────────────────────────────── | |
| raw_layout = data.get("layout") | |
| raw_regions: list = [] | |
| if isinstance(raw_layout, dict): | |
| raw_regions = raw_layout.get("regions") or [] | |
| valid_regions: list[dict] = [] | |
| for i, raw_region in enumerate(raw_regions): | |
| try: | |
| region = Region.model_validate(raw_region) | |
| valid_regions.append(region.model_dump()) | |
| except (ValidationError, ValueError, KeyError, TypeError) as exc: | |
| logger.warning( | |
| "Région ignorée — bbox ou champ invalide", | |
| extra={"index": i, "region": raw_region, "error": str(exc)}, | |
| ) | |
| layout: dict = {"regions": valid_regions} | |
| # ── OCR ───────────────────────────────────────────────────────────────── | |
| raw_ocr = data.get("ocr") | |
| ocr: OCRResult | |
| if raw_ocr and isinstance(raw_ocr, dict): | |
| try: | |
| ocr = OCRResult.model_validate(raw_ocr) | |
| except ValidationError as exc: | |
| logger.warning( | |
| "OCR invalide — utilisation des valeurs par défaut", | |
| extra={"error": str(exc)}, | |
| ) | |
| ocr = OCRResult() | |
| else: | |
| ocr = OCRResult() | |
| logger.info( | |
| "Réponse IA parsée", | |
| extra={ | |
| "regions_total": len(raw_regions), | |
| "regions_valides": len(valid_regions), | |
| "regions_ignorees": len(raw_regions) - len(valid_regions), | |
| "ocr_confidence": ocr.confidence, | |
| }, | |
| ) | |
| return layout, ocr | |