80 lines
2.7 KiB
Python
80 lines
2.7 KiB
Python
"""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)
|