gwoe-antragspruefer/app/ports/llm_bewerter.py
Dotty Dotter a0559333e8 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) <noreply@anthropic.com>
2026-05-06 01:53:29 +02:00

55 lines
1.8 KiB
Python

"""LlmBewerter — Port für den LLM-Call in der Antragsbewertung.
Trennt die *Rohantwort* des LLMs (JSON-String) vom umgebenden
Application-Flow (Retry, Prompt-Composition, Citation-Binding). Die
Retry-Logik samt Temperatur-Escalation bleibt Adapter-Detail — ein
zweiter Adapter (Claude, OpenAI-kompatible Proxies) kann eine ganz
andere Strategie wählen.
Ein späterer Tag-Schritt (Kapitel 10.5 der DDD-Bewertung) kapselt
zusätzlich die JSON-Parse-Kaskade hinter dem Port; heute bekommt der
Caller noch einen JSON-String zurück.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
class LlmRequest:
"""Alles, was der Adapter zum Generieren der Bewertung braucht —
inkl. Retry-Verhalten auf der Adapter-Seite."""
system_prompt: str
user_prompt: str
model: str = "qwen-plus"
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
class LlmBewerter(Protocol):
"""Port: wandelt einen Prompt in einen JSON-String (LLM-Rohantwort).
Der Adapter kümmert sich um:
- Markdown-Fence-Entfernung,
- JSON-Parse-Retry mit steigender Temperatur,
- Content-Fingerprint-Logging zur Forensik.
Raises:
json.JSONDecodeError: wenn alle Retries scheitern. Höhere Schichten
behandeln das als Fehlschlag der Analyse.
"""
async def bewerte(self, request: LlmRequest) -> dict: ...