"""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})') def _loads(s: str): """json.loads mit EINER Reparaturstufe für rohe LaTeX-Backslashes. 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 None 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)