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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
197
src/json_utils.py
Normale Datei
@@ -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 ".!?)]}\"'…:*_`-"
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren