fix(agents): nicht maskierte Anfuehrungszeichen in Modell-JSON reparieren

Beobachtet am 31.07.2026 auf Staging: Modelle schliessen deutsche Zitate
gelegentlich mit einem geraden Anfuehrungszeichen, das das JSON an dieser
Stelle bricht. Die Folgen waren still und teuer:
  - Lagebild von Lage 47 brach mitten im Satz ab (738 statt ~4000 Zeichen),
    weil der Regex-Fallback nur bis zum Stoerzeichen rettete
  - ein kompletter Recherche-Durchlauf von Lage 45 ging verloren
  - sechs weitere Durchlaeufe fielen auf die verlustbehaftete
    Einzelobjekt-Rettung zurueck
Betroffen war vor allem der EU-Weg (Opus 4.6), der CLI-Weg aber ebenfalls.

Neu: src/json_utils.py repariert solche Antworten, indem es beim Durchlauf
mitfuehrt, ob eine Zeichenkette offen ist, und ein Anfuehrungszeichen maskiert,
auf das kein gueltiger JSON-Fortsetzer folgt (Komma nur dann als Ende, wenn
danach wirklich ein Wert oder Schluessel beginnt). Roh eingebettete
Zeilenumbrueche werden ebenfalls maskiert. Repariert wird ausschliesslich als
Rueckfallebene, nachdem der normale Parser gescheitert ist, gueltiges JSON
bleibt unangetastet.

Eingebunden in analyzer, factchecker und researcher. Zusaetzlich meldet der
Analyzer jetzt als ERROR, wenn der letzte Fallback ein erkennbar
abgeschnittenes Lagebild liefert, statt das still hinzunehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-01 11:32:35 +00:00
Ursprung b7a2a120cd
Commit 768150c302
4 geänderte Dateien mit 244 neuen und 2 gelöschten Zeilen

Datei anzeigen

@@ -1108,6 +1108,15 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn
except json.JSONDecodeError:
pass
# Versuch 2b: Nicht maskierte Anfuehrungszeichen im Text reparieren.
# Haeufigster Bruch (deutsche Zitate mit geradem Schlusszeichen); ohne
# diesen Schritt rettete der Regex-Fallback unten nur ein Bruchstueck
# und das Lagebild brach still mitten im Satz ab.
from json_utils import extract_json_object as _forgiving_object
repaired_obj = _forgiving_object(response)
if isinstance(repaired_obj, dict) and "summary" in repaired_obj:
return repaired_obj
# Versuch 3: Abgeschnittenes JSON reparieren (haeufig bei langen Antworten)
candidate = match.group() if match else cleaned
repaired = self._repair_truncated_json(candidate)
@@ -1117,7 +1126,18 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn
# Versuch 4: summary per Regex extrahieren als letzter Fallback
fallback = self._extract_summary_fallback(response)
if fallback:
logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
from json_utils import looks_truncated as _looks_truncated
gerettet = fallback.get("summary", "")
if _looks_truncated(gerettet):
# Sichtbar machen statt still hinnehmen: hier geht Inhalt verloren.
logger.error(
"JSON-Parse fehlgeschlagen und Regex-Fallback liefert ein "
"abgeschnittenes Lagebild (%d von vermutlich mehr Zeichen). "
"Antwortlaenge %d, Ende: %r",
len(gerettet), len(response), gerettet[-80:],
)
else:
logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
return fallback
logger.error(

Datei anzeigen

@@ -869,5 +869,16 @@ class FactCheckerAgent:
except json.JSONDecodeError:
pass
logger.warning("Konnte Faktencheck-Antwort nicht als JSON parsen")
# Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche Zitate
# in claim/evidence). Ohne diesen Schritt lieferte der Faktencheck bei
# einem einzigen Stoerzeichen still eine leere Liste.
from json_utils import extract_json_array as _forgiving_array
repaired = _forgiving_array(response)
if isinstance(repaired, list):
return self._validate_facts(repaired, articles=articles)
logger.warning(
"Konnte Faktencheck-Antwort nicht als JSON parsen (Laenge: %d, Anfang: %r)",
len(response or ""), (response or "")[:200],
)
return []

Datei anzeigen

@@ -913,6 +913,20 @@ class ResearcherAgent:
if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
return obj["articles"]
# 3b) Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche
# Zitate in content_summary). Ohne diesen Schritt fiel die Auswertung
# auf die Einzelobjekt-Rettung zurueck, die Artikel verlieren kann, oder
# es ging ein kompletter Recherche-Durchlauf verloren.
from json_utils import extract_json_array as _forgiving_array, extract_json_object as _forgiving_object
repaired_arr = _forgiving_array(text)
if isinstance(repaired_arr, list):
logger.info("JSON-Reparatur: %d Artikel gerettet", len(repaired_arr))
return repaired_arr
repaired_obj = _forgiving_object(text)
if isinstance(repaired_obj, dict) and isinstance(repaired_obj.get("articles"), list):
logger.info("JSON-Reparatur: %d Artikel gerettet (Objektform)", len(repaired_obj["articles"]))
return repaired_obj["articles"]
# 4) Recovery: einzelne Headline-Objekte aus Fliesstext
recovered = []
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):