From a0559333e81b57727babaf7657289eef1913e752 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 6 May 2026 01:53:29 +0200 Subject: [PATCH] fix(#170): JSON-Parse-Fehler im PM-Generator (unescaped Newlines) Beobachtung beim Force-Regen: alle 2 Retries scheiterten mit "Invalid control character at: line 3 column 275". qwen-max produziert JSON mit rohen \n statt \\n im body-String, was json.loads sprengt. Zwei Fixes parallel: **1. response_format={"type": "json_object"}** als optionaler Mode im LlmRequest. PM-Generator setzt das jetzt. DashScope unterstuetzt das fuer qwen-max + qwen-plus und zwingt valide JSON-Strings. **2. Newline-Recovery als Fallback** im QwenBewerter: `_recover_unescaped_newlines` iteriert char-weise mit String-Tracking, ersetzt unescaped \n/\r/\t in Strings durch \\n/\\r/\\t. Backslash- Folgen bleiben unangetastet. Wird vor dem Retry-Re-throw versucht. Bewertungs-Pfad (analyzer.py) bekommt json_object_mode=False als Default, um die bewaehrte Retry-Semantik nicht zu aendern. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/adapters/qwen_bewerter.py | 59 +++++++++++++++++++++++++++++++++++ app/ports/llm_bewerter.py | 6 ++++ app/presse_generator.py | 1 + 3 files changed, 66 insertions(+) diff --git a/app/adapters/qwen_bewerter.py b/app/adapters/qwen_bewerter.py index 24c80cf..efd467f 100644 --- a/app/adapters/qwen_bewerter.py +++ b/app/adapters/qwen_bewerter.py @@ -31,6 +31,44 @@ def _content_fingerprint(content: str) -> str: return f"len={len(content)} sha1={h}" +def _recover_unescaped_newlines(content: str) -> str: + """Versucht JSON-Strings mit unescaped Newlines zu reparieren. + + Iteriert character-weise, behaelt einen "im String"-Status (per + Anfuehrungszeichen), und ersetzt rohe \\n / \\r / \\t innerhalb von + Strings durch ihre escapeten Aequivalente. Lassen Backslash-Folgen + unangetastet (kein doppel-Escapen). + + Konservativ: bei Backslash-Status (kommt nach \\) wird nicht + ersetzt, dadurch bleiben bereits-escapete Sequenzen erhalten. + """ + out = [] + in_string = False + escape_next = False + for ch in content: + if escape_next: + out.append(ch) + escape_next = False + continue + if ch == "\\": + out.append(ch) + escape_next = True + continue + if ch == '"': + in_string = not in_string + out.append(ch) + continue + if in_string: + if ch == "\n": + out.append("\\n"); continue + if ch == "\r": + out.append("\\r"); continue + if ch == "\t": + out.append("\\t"); continue + out.append(ch) + return "".join(out) + + def _strip_markdown_fences(content: str) -> str: """Entfernt Markdown-Code-Fences, die Qwen trotz Prompt manchmal ergänzt. @@ -78,6 +116,12 @@ class QwenBewerter: last_error: Optional[Exception] = None for attempt in range(request.max_retries): + extra_kwargs = {} + if request.json_object_mode: + # DashScope (OpenAI-kompatibel) unterstuetzt + # response_format={"type":"json_object"} fuer qwen-max + plus — + # zwingt den LLM zu valid JSON ohne unescaped Newlines. + extra_kwargs["response_format"] = {"type": "json_object"} response = await client.chat.completions.create( model=request.model, messages=[ @@ -86,6 +130,7 @@ class QwenBewerter: ], temperature=request.base_temperature + (attempt * 0.1), max_tokens=request.max_tokens, + **extra_kwargs, ) content = response.choices[0].message.content.strip() content = _strip_markdown_fences(content) @@ -93,6 +138,20 @@ class QwenBewerter: try: return json.loads(content) except json.JSONDecodeError as e: + # Recovery-Versuch: unescaped Newlines in String-Werten. + # Beobachtetes Muster: LLM produziert `"body": "Zeile1\nZeile2"` + # mit echten Newline-Bytes statt \n-Sequenzen. + recovered = _recover_unescaped_newlines(content) + if recovered != content: + try: + result = json.loads(recovered) + logger.info( + "LLM JSON recovered via newline-escape (attempt %d)", + attempt + 1, + ) + return result + except json.JSONDecodeError: + pass last_error = e logger.warning( "LLM JSON parse error attempt %d/%d (%s) — content %s", diff --git a/app/ports/llm_bewerter.py b/app/ports/llm_bewerter.py index 056058c..580f796 100644 --- a/app/ports/llm_bewerter.py +++ b/app/ports/llm_bewerter.py @@ -28,6 +28,12 @@ class LlmRequest: max_retries: int = 3 max_tokens: int = 4000 base_temperature: float = 0.3 + # Wenn True, wird der DashScope-API ``response_format={"type":"json_object"}`` + # gesendet. Verhindert unescaped-Newlines-Bugs im LLM-Output. Bisher + # nur fuer den Pressemitteilungs-Generator (#170 Phase 4) benutzt; + # der Bewertungs-Pfad in analyzer.py laesst das auf False um die + # bewaehrte Retry-Semantik nicht zu aendern. + json_object_mode: bool = False @runtime_checkable diff --git a/app/presse_generator.py b/app/presse_generator.py index 7b5931a..6944eee 100644 --- a/app/presse_generator.py +++ b/app/presse_generator.py @@ -308,6 +308,7 @@ async def generate_draft( base_temperature=0.3, max_tokens=1500, max_retries=2, + json_object_mode=True, ) result = await bewerter.bewerte(req)