Spaces:
Build error
Build error
Merge pull request #41 from maribakulj/claude/fix-manifest-analysis-performance-BIMKl
Browse files
backend/app/services/ai/response_parser.py
CHANGED
|
@@ -5,10 +5,15 @@ Comportement :
|
|
| 5 |
- JSON non parseable → ParseError (toute la page échoue)
|
| 6 |
- Région avec bbox invalide → région ignorée + log (la page continue)
|
| 7 |
- OCR invalide → OCRResult() par défaut + log (la page continue)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
# 1. stdlib
|
| 10 |
import json
|
| 11 |
import logging
|
|
|
|
| 12 |
|
| 13 |
# 2. third-party
|
| 14 |
from pydantic import ValidationError
|
|
@@ -23,6 +28,77 @@ class ParseError(Exception):
|
|
| 23 |
"""Levée si la réponse de l'IA est un JSON invalide ou structurellement incorrecte."""
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]:
|
| 27 |
"""Parse la réponse textuelle de l'IA en layout dict + OCRResult validés.
|
| 28 |
|
|
@@ -30,7 +106,8 @@ def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]:
|
|
| 30 |
faire échouer toute la page. Un JSON non parseable lève ParseError.
|
| 31 |
|
| 32 |
Gère les balises Markdown (```json ... ```) que certains modèles ajoutent
|
| 33 |
-
malgré les instructions
|
|
|
|
| 34 |
|
| 35 |
Args:
|
| 36 |
raw_text: texte brut retourné par l'IA (censé être du JSON strict).
|
|
@@ -48,12 +125,16 @@ def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]:
|
|
| 48 |
end = len(lines) - 1 if lines[-1].strip() == "```" else len(lines)
|
| 49 |
text = "\n".join(lines[1:end])
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
raise ParseError(
|
| 55 |
-
f"Réponse IA non parseable en JSON
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
if not isinstance(data, dict):
|
| 59 |
raise ParseError(
|
|
|
|
| 5 |
- JSON non parseable → ParseError (toute la page échoue)
|
| 6 |
- Région avec bbox invalide → région ignorée + log (la page continue)
|
| 7 |
- OCR invalide → OCRResult() par défaut + log (la page continue)
|
| 8 |
+
|
| 9 |
+
Le parser est tolérant : il extrait le premier objet JSON valide du texte
|
| 10 |
+
même si l'IA ajoute du texte autour, des balises Markdown, des virgules
|
| 11 |
+
en trop, ou des échappements Unicode cassés.
|
| 12 |
"""
|
| 13 |
# 1. stdlib
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
+
import re
|
| 17 |
|
| 18 |
# 2. third-party
|
| 19 |
from pydantic import ValidationError
|
|
|
|
| 28 |
"""Levée si la réponse de l'IA est un JSON invalide ou structurellement incorrecte."""
|
| 29 |
|
| 30 |
|
| 31 |
+
def _extract_json_object(text: str) -> str:
|
| 32 |
+
"""Extrait le premier objet JSON { ... } complet du texte.
|
| 33 |
+
|
| 34 |
+
Les VLMs renvoient souvent du texte avant/après le JSON, ou plusieurs
|
| 35 |
+
blocs JSON concaténés. Cette fonction trouve le premier '{' et compte
|
| 36 |
+
les accolades pour trouver le '}' fermant correspondant, en ignorant
|
| 37 |
+
les accolades à l'intérieur des chaînes.
|
| 38 |
+
"""
|
| 39 |
+
start = text.find("{")
|
| 40 |
+
if start == -1:
|
| 41 |
+
return text
|
| 42 |
+
|
| 43 |
+
depth = 0
|
| 44 |
+
in_string = False
|
| 45 |
+
escape = False
|
| 46 |
+
|
| 47 |
+
for i in range(start, len(text)):
|
| 48 |
+
c = text[i]
|
| 49 |
+
if escape:
|
| 50 |
+
escape = False
|
| 51 |
+
continue
|
| 52 |
+
if c == "\\":
|
| 53 |
+
escape = True
|
| 54 |
+
continue
|
| 55 |
+
if c == '"' and not escape:
|
| 56 |
+
in_string = not in_string
|
| 57 |
+
continue
|
| 58 |
+
if in_string:
|
| 59 |
+
continue
|
| 60 |
+
if c == "{":
|
| 61 |
+
depth += 1
|
| 62 |
+
elif c == "}":
|
| 63 |
+
depth -= 1
|
| 64 |
+
if depth == 0:
|
| 65 |
+
return text[start : i + 1]
|
| 66 |
+
|
| 67 |
+
# Pas de fermeture trouvée — retourner depuis le premier '{'
|
| 68 |
+
return text[start:]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _fix_common_json_issues(text: str) -> str:
|
| 72 |
+
"""Corrige les erreurs JSON courantes des VLMs.
|
| 73 |
+
|
| 74 |
+
- Trailing commas avant } ou ]
|
| 75 |
+
- Échappements Unicode invalides (\\uXXXX incomplets)
|
| 76 |
+
"""
|
| 77 |
+
# Trailing commas : ,} ou ,]
|
| 78 |
+
text = re.sub(r",\s*([}\]])", r"\1", text)
|
| 79 |
+
# Échappements unicode invalides — remplacer par un espace
|
| 80 |
+
text = re.sub(r"\\u(?![0-9a-fA-F]{4})[^\"]*", " ", text)
|
| 81 |
+
return text
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _try_parse_json(text: str) -> dict | None:
|
| 85 |
+
"""Tente de parser du JSON, avec nettoyage progressif en cas d'échec."""
|
| 86 |
+
# Tentative 1 : tel quel
|
| 87 |
+
try:
|
| 88 |
+
return json.loads(text)
|
| 89 |
+
except json.JSONDecodeError:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
# Tentative 2 : corrections courantes
|
| 93 |
+
fixed = _fix_common_json_issues(text)
|
| 94 |
+
try:
|
| 95 |
+
return json.loads(fixed)
|
| 96 |
+
except json.JSONDecodeError:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]:
|
| 103 |
"""Parse la réponse textuelle de l'IA en layout dict + OCRResult validés.
|
| 104 |
|
|
|
|
| 106 |
faire échouer toute la page. Un JSON non parseable lève ParseError.
|
| 107 |
|
| 108 |
Gère les balises Markdown (```json ... ```) que certains modèles ajoutent
|
| 109 |
+
malgré les instructions, ainsi que le texte avant/après le JSON et les
|
| 110 |
+
erreurs de formatage courantes des VLMs.
|
| 111 |
|
| 112 |
Args:
|
| 113 |
raw_text: texte brut retourné par l'IA (censé être du JSON strict).
|
|
|
|
| 125 |
end = len(lines) - 1 if lines[-1].strip() == "```" else len(lines)
|
| 126 |
text = "\n".join(lines[1:end])
|
| 127 |
|
| 128 |
+
# Extraction du premier objet JSON (ignore le texte autour)
|
| 129 |
+
text = _extract_json_object(text)
|
| 130 |
+
|
| 131 |
+
data = _try_parse_json(text)
|
| 132 |
+
|
| 133 |
+
if data is None:
|
| 134 |
raise ParseError(
|
| 135 |
+
f"Réponse IA non parseable en JSON après nettoyage. "
|
| 136 |
+
f"Début du texte brut : {raw_text[:300]!r}"
|
| 137 |
+
)
|
| 138 |
|
| 139 |
if not isinstance(data, dict):
|
| 140 |
raise ParseError(
|