"""Robustes JSON-Parsen von LLM-Antworten: Modelle liefern Fences, Prosa drumherum oder Präfixe — wir suchen das erste vollständige JSON-Objekt/-Array.""" import json import re _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) # LaTeX in JSON-Strings ("$\in$", "\Sigma") ist ein INVALIDES Escape → json.loads # wirft (kostete bis zu 29 % Parse-Fehler bei Mathe-Stages). Nur ungültige Escapes # verdoppeln: gültige (\" \\ \/ \b \f \n \r \t \uXXXX) bleiben; \u ohne 4 # Hex-Ziffern (\underline) zählt als ungültig. _UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})') _ESCAPE_JE_ZEICHEN = {"\n": "\\n", "\r": "\\r", "\t": "\\t"} def _iterativ_reparieren(s: str): """Positionsgenaue Reparatur am ORIGINAL (das Regex-Doubling beschädigt \\\\x-Folgen): rohe Kontrollzeichen in Strings escapen (Modelle zitieren mehrzeilige Passagen wörtlich — Markup-Zitat-Regel), ungültige Escapes doubeln. Iterativ an der jeweils ersten Fehlerstelle, nur im Fehlerfall.""" for _ in range(300): try: return json.loads(s) except json.JSONDecodeError as e: if e.pos >= len(s): return None if "Invalid control character" in e.msg: s = s[:e.pos] + _ESCAPE_JE_ZEICHEN.get(s[e.pos], " ") + s[e.pos + 1:] elif "Invalid \\escape" in e.msg: s = s[:e.pos] + "\\" + s[e.pos:] else: return None return None def _loads(s: str): """json.loads mit Reparaturstufen für rohe LaTeX-Backslashes und rohe Kontrollzeichen in Strings. Valides JSON durchläuft unverändert (Reparatur nur im Fehlerfall).""" try: return json.loads(s) except ValueError: try: return json.loads(_UNGUELTIGES_ESCAPE.sub(r"\\\\", s)) except ValueError: return _iterativ_reparieren(s) def parse(text: str): """→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry. Jeden Code-Fence UND den Gesamttext durchprobieren; erster Treffer gewinnt. (Nur den ersten Fence zu nehmen verlor JSON, das nach einem Prosa-Beispiel- Fence stand.)""" if not text: return None for kandidat in [m.group(1) for m in _FENCE.finditer(text)] + [text]: daten = _ein_kandidat(kandidat.strip()) if daten is not None: return daten return None def _ein_kandidat(text: str): """Erstes vollständiges JSON-Objekt/-Array in `text` (Klammer-Balance) oder None. Das FRÜHESTE Klammerzeichen entscheidet — sonst gewinnt ein {…} im Array-Inneren.""" if not text: return None erste = sorted((("{", "}"), ("[", "]")), key=lambda p: text.find(p[0]) if p[0] in text else len(text)) for start_ch, end_ch in erste: start = text.find(start_ch) if start < 0: continue depth = 0 in_str = False esc = False for i in range(start, len(text)): c = text[i] if esc: esc = False continue if c == "\\": esc = in_str continue if c == '"': in_str = not in_str continue if in_str: continue if c == start_ch: depth += 1 elif c == end_ch: depth -= 1 if depth == 0: daten = _loads(text[start:i + 1]) if daten is not None: return daten break return _loads(text)