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 ".!?)]}\"'…:*_`-"