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: except json.JSONDecodeError:
pass 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) # Versuch 3: Abgeschnittenes JSON reparieren (haeufig bei langen Antworten)
candidate = match.group() if match else cleaned candidate = match.group() if match else cleaned
repaired = self._repair_truncated_json(candidate) repaired = self._repair_truncated_json(candidate)
@@ -1117,6 +1126,17 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn
# Versuch 4: summary per Regex extrahieren als letzter Fallback # Versuch 4: summary per Regex extrahieren als letzter Fallback
fallback = self._extract_summary_fallback(response) fallback = self._extract_summary_fallback(response)
if fallback: if fallback:
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") logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
return fallback return fallback

Datei anzeigen

@@ -869,5 +869,16 @@ class FactCheckerAgent:
except json.JSONDecodeError: except json.JSONDecodeError:
pass 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 [] return []

Datei anzeigen

@@ -913,6 +913,20 @@ class ResearcherAgent:
if isinstance(obj, dict) and isinstance(obj.get("articles"), list): if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
return obj["articles"] 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 # 4) Recovery: einzelne Headline-Objekte aus Fliesstext
recovered = [] recovered = []
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL): for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):

197
src/json_utils.py Normale Datei
Datei anzeigen

@@ -0,0 +1,197 @@
"""Nachsichtiges Auslesen von JSON aus Modell-Antworten.
Hintergrund. Die Agenten verlangen von den Modellen JSON. Gelegentlich
scheitert das an einer Kleinigkeit, mit Abstand am haeufigsten an einem nicht
maskierten geraden Anfuehrungszeichen mitten in einem Textwert. Typisch ist ein
deutsches Zitat, das mit dem unteren Zeichen geoeffnet und mit einem geraden
Zeichen geschlossen wird:
{"summary": "Sánchez nannte es „Angriff auf die Integritaet" und kuendigte..."}
^ bricht das JSON
Der Standard-Parser bricht dort ab. Die nachgelagerten Notfall-Pfade der Agenten
retten dann oft nur das Bruchstueck bis zu dieser Stelle, was still zu
abgeschnittenen Lagebildern und verlorenen Recherche-Ergebnissen fuehrt
(beobachtet am 31.07.2026 auf Staging, vor allem auf dem EU-Modellweg).
Dieses Modul repariert genau solche Faelle. Wichtig fuer die Sicherheit,
repariert wird ausschliesslich als Rueckfallebene, nachdem der normale Parser
gescheitert ist. Gueltiges JSON wird nie angefasst.
"""
import json
import logging
from typing import Any
logger = logging.getLogger("osint.json_utils")
# Zeichen, die nach einem schliessenden Anfuehrungszeichen eindeutig fuer ein
# echtes Kettenende sprechen. Beim Komma genuegt das nicht, weil auch im
# Fliesstext ein Zitat vor einem Komma stehen kann. Dort wird zusaetzlich
# geprueft, ob danach ueberhaupt ein gueltiger JSON-Wert beginnt.
_STRING_END_FOLLOWERS = "}]:"
_WHITESPACE = " \t\r\n"
_ESCAPE_IN_STRING = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
# Zeichen, mit denen ein JSON-Wert oder Schluessel beginnen kann
_VALUE_STARTERS = '"{['
_LITERALS = ("true", "false", "null")
def _endet_kette(text: str, pos: int) -> bool:
"""Beurteilt, ob das Anfuehrungszeichen an pos eine Zeichenkette beendet."""
n = len(text)
j = pos + 1
while j < n and text[j] in _WHITESPACE:
j += 1
if j >= n:
return True
if text[j] in _STRING_END_FOLLOWERS:
return True
if text[j] != ",":
return False
# Komma. Ein echtes Kettenende wird von einem neuen Wert oder Schluessel
# gefolgt, im Fliesstext dagegen von gewoehnlichen Woertern.
k = j + 1
while k < n and text[k] in _WHITESPACE:
k += 1
if k >= n:
return True
if text[k] in _VALUE_STARTERS or text[k].isdigit() or text[k] == "-":
return True
rest = text[k:k + 5]
return any(rest.startswith(lit) for lit in _LITERALS)
def repair_json_text(text: str) -> str:
"""Maskiert Anfuehrungszeichen und Zeilenumbrueche, die faelschlich roh in
JSON-Textwerten stehen.
Verfahren. Der Text wird einmal zeichenweise durchlaufen und mitgefuehrt, ob
wir uns gerade in einer Zeichenkette befinden. Trifft der Durchlauf innerhalb
einer Zeichenkette auf ein Anfuehrungszeichen, entscheidet ein Blick auf das
naechste nicht-leere Zeichen, ob es die Kette wirklich beendet. Steht dort
Fliesstext statt eines Struktur-Zeichens, wird maskiert statt beendet.
"""
out: list[str] = []
in_string = False
escaped = False
i = 0
n = len(text)
while i < n:
ch = text[i]
if escaped:
out.append(ch)
escaped = False
i += 1
continue
if ch == "\\":
out.append(ch)
escaped = True
i += 1
continue
if ch == '"':
if not in_string:
in_string = True
out.append(ch)
elif _endet_kette(text, i):
in_string = False
out.append(ch)
else:
out.append('\\"')
i += 1
continue
if in_string and ch in _ESCAPE_IN_STRING:
out.append(_ESCAPE_IN_STRING[ch])
i += 1
continue
out.append(ch)
i += 1
return "".join(out)
def loads_forgiving(text: str, context: str = "") -> Any | None:
"""json.loads mit Reparatur-Rueckfallebene. Gibt None zurueck, wenn auch die
Reparatur nichts Verwertbares ergibt."""
if not text:
return None
try:
return json.loads(text)
except (json.JSONDecodeError, ValueError):
pass
try:
data = json.loads(repair_json_text(text))
except (json.JSONDecodeError, ValueError):
return None
logger.info("JSON-Reparatur erfolgreich%s (%d Zeichen)", f" [{context}]" if context else "", len(text))
return data
def _strip_fences(text: str) -> str:
"""Entfernt umschliessende Markdown-Code-Zaeune."""
cleaned = (text or "").strip()
if cleaned.startswith("```"):
nl = cleaned.find("\n")
if nl != -1:
cleaned = cleaned[nl + 1:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3].rstrip()
return cleaned.strip()
def _extract_block(text: str, opener: str, closer: str, want: type) -> Any | None:
"""Sucht den ersten vollstaendigen JSON-Block der gewuenschten Art, erst
unveraendert, dann repariert."""
if not text:
return None
for candidate in (text, repair_json_text(text)):
decoder = json.JSONDecoder()
idx = 0
while True:
start = candidate.find(opener, idx)
if start == -1:
break
try:
obj, _ = decoder.raw_decode(candidate, start)
except (json.JSONDecodeError, ValueError):
idx = start + 1
continue
if isinstance(obj, want):
return obj
idx = start + 1
# Zweiter Durchgang nur, wenn die Reparatur ueberhaupt etwas geaendert hat
if candidate is not text:
break
return None
def extract_json_object(text: str) -> dict | None:
"""Erstes vollstaendiges JSON-Objekt im Text, mit Reparatur-Rueckfall."""
cleaned = _strip_fences(text)
direct = loads_forgiving(cleaned)
if isinstance(direct, dict):
return direct
return _extract_block(text, "{", "}", dict)
def extract_json_array(text: str) -> list | None:
"""Erstes vollstaendiges JSON-Array im Text, mit Reparatur-Rueckfall."""
cleaned = _strip_fences(text)
direct = loads_forgiving(cleaned)
if isinstance(direct, list):
return direct
return _extract_block(text, "[", "]", list)
def looks_truncated(text: str) -> bool:
"""Heuristik, ob ein geretteter Text mitten im Satz abbricht. Dient nur dem
Protokoll, damit stille Teilverluste sichtbar werden."""
s = (text or "").rstrip()
if not s:
return True
return s[-1] not in ".!?)]}\"'…:*_`-"