File size: 6,152 Bytes
9bb793c
 
 
 
 
 
 
90027d3
 
 
 
9bb793c
 
 
 
90027d3
9bb793c
 
 
 
 
 
 
 
 
 
 
 
 
 
90027d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9bb793c
 
 
 
 
 
 
90027d3
 
9bb793c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90027d3
 
 
 
 
 
9bb793c
90027d3
 
 
9bb793c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9097545
9bb793c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""
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