From 768150c30258a6abf26583f37bc1e2efc4bbd44c Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 1 Aug 2026 11:32:35 +0000 Subject: [PATCH] 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 --- src/agents/analyzer.py | 22 ++++- src/agents/factchecker.py | 13 ++- src/agents/researcher.py | 14 +++ src/json_utils.py | 197 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 src/json_utils.py diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index 0cfec86..47dd134 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -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( diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index 9f83482..66e65be 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -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 [] diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 705342d..ecea3e7 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -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): diff --git a/src/json_utils.py b/src/json_utils.py new file mode 100644 index 0000000..3ebe16f --- /dev/null +++ b/src/json_utils.py @@ -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 ".!?)]}\"'…:*_`-"