Files
creator2/backend/jsonx.py
2026-07-13 12:31:25 +02:00

104 lines
3.6 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})')
_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)