Translator-Agent: dedizierter Haiku-Pass fuer fehlende DE-Uebersetzungen
Bisher haben translations als Teil der Analyzer-JSON-Antwort gelebt
("translations": [...]). Bei vielen Artikeln pro Refresh hat das LLM die
Translations regelmaessig weggelassen (Output-Token-Druck), insbesondere
content_de (lange Texte werden zuerst gestrichen). Folge: viele englische
Artikel ohne deutsche Headline/Inhalt im Frontend.
Aenderungen:
- Neuer Agent src/agents/translator.py:
* translate_articles_batch / translate_articles
* Nutzt CLAUDE_MODEL_FAST (Haiku) - billig
* Batch-Size 5 (mit Reserve gegen Output-Truncate)
* Robustes JSON-Parsing: Markdown-Codefence, Truncate-Fallback,
extrahiert auch unvollstaendige Antworten
* Idempotent: Caller filtert auf fehlende headline_de/content_de
- analyzer.py: translations aus 4 Prompt-Templates entfernt (adhoc/research
x analyze/enhance) und Fallback-Return-Dict bereinigt -> Analyzer-Output
wird kompakter und zuverlaessiger
- orchestrator.py:
* Alter Translation-INSERT-Block entfernt (analysis.translations wird
nicht mehr genutzt)
* Nach Analyse + db.commit + cancel-check neuer Translator-Call:
SELECT WHERE language!=de AND (headline_de OR content_de fehlt),
translate_articles, normalize_german_umlauts, COALESCE-UPDATE
* Vor post_refresh_qc -> normalize_umlaut_articles greift auch frische
Uebersetzungen
* Failure-tolerant: Translator-Fehler bricht Refresh nicht ab
Backfill: migrations/migrate_translations_2026-05-03.py im Verwaltungs-Repo.
Dieser Commit ist enthalten in:
229
src/agents/translator.py
Normale Datei
229
src/agents/translator.py
Normale Datei
@@ -0,0 +1,229 @@
|
||||
"""Translator-Agent: uebersetzt fremdsprachige Artikel ins Deutsche.
|
||||
|
||||
Eigener Agent (separat vom Analyzer), damit Token-Limits nicht zwischen
|
||||
Lagebild und Uebersetzung konkurrieren. Nutzt CLAUDE_MODEL_FAST (Haiku) in
|
||||
Batches.
|
||||
|
||||
Aufgerufen vom Orchestrator nach analyzer.analyze() und vor post_refresh_qc.
|
||||
Backfill-Skript nutzt dieselbe Funktion fuer rueckwirkendes Auffuellen.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
|
||||
from config import CLAUDE_MODEL_FAST
|
||||
|
||||
logger = logging.getLogger("osint.translator")
|
||||
|
||||
# Pro Batch nicht mehr als so viele Artikel an Claude geben.
|
||||
# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
|
||||
# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
|
||||
# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
|
||||
DEFAULT_BATCH_SIZE = 5
|
||||
|
||||
# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
|
||||
# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
|
||||
CONTENT_INPUT_MAX = 1200
|
||||
|
||||
# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
|
||||
CONTENT_OUTPUT_MAX = 1000
|
||||
|
||||
|
||||
def _extract_complete_objects(text: str) -> list[dict]:
|
||||
"""Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
|
||||
|
||||
Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
|
||||
"""
|
||||
results = []
|
||||
depth = 0
|
||||
start = -1
|
||||
in_string = False
|
||||
escape = False
|
||||
for i, ch in enumerate(text):
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"' and not escape:
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "{":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
obj_text = text[start:i + 1]
|
||||
try:
|
||||
obj = json.loads(obj_text)
|
||||
if isinstance(obj, dict):
|
||||
results.append(obj)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
start = -1
|
||||
return results
|
||||
|
||||
|
||||
def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
|
||||
"""Bauen den Translation-Prompt fuer eine Batch."""
|
||||
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
|
||||
|
||||
items = []
|
||||
for a in articles:
|
||||
items.append({
|
||||
"id": a["id"],
|
||||
"headline": a.get("headline", "") or "",
|
||||
"content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
|
||||
"source_lang": a.get("language", "en"),
|
||||
})
|
||||
|
||||
return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
|
||||
Uebersetze die folgenden Artikel nach {lang_label}.
|
||||
|
||||
WICHTIG:
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
|
||||
Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
|
||||
- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
|
||||
- Headline kurz und buendig wie im Original.
|
||||
- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
|
||||
- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
|
||||
kopiere headline und content unveraendert.
|
||||
|
||||
Antworte AUSSCHLIESSLICH als JSON-Array - eine Liste von Objekten in der Form:
|
||||
[{{"id": <int>, "headline_de": "<uebersetzter Titel>", "content_de": "<uebersetzter Text>"}}, ...]
|
||||
|
||||
Keine Einleitung, keine Erklaerung, nur das JSON-Array.
|
||||
|
||||
ARTIKEL:
|
||||
{json.dumps(items, ensure_ascii=False, indent=2)}
|
||||
"""
|
||||
|
||||
|
||||
def _parse_response(text: str) -> list[dict]:
|
||||
"""Robustes JSON-Array-Parsing.
|
||||
|
||||
Handhabt:
|
||||
- reines JSON
|
||||
- JSON in Markdown-Codefence ```json ... ```
|
||||
- abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
|
||||
"""
|
||||
text = text.strip()
|
||||
# Markdown-Codefence entfernen
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```\s*$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Erst Array versuchen
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
# Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
|
||||
data = _extract_complete_objects(text)
|
||||
else:
|
||||
data = _extract_complete_objects(text)
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
|
||||
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = item.get("id")
|
||||
if not isinstance(aid, int):
|
||||
try:
|
||||
aid = int(aid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
cleaned.append({
|
||||
"id": aid,
|
||||
"headline_de": (item.get("headline_de") or "").strip() or None,
|
||||
"content_de": (item.get("content_de") or "").strip() or None,
|
||||
})
|
||||
return cleaned
|
||||
|
||||
|
||||
async def translate_articles_batch(
|
||||
articles: list[dict],
|
||||
output_lang: str = "de",
|
||||
) -> tuple[list[dict], ClaudeUsage]:
|
||||
"""Uebersetzt eine Batch von Artikeln.
|
||||
|
||||
Erwartet articles als Liste von Dicts mit den Feldern id, headline,
|
||||
content_original, language.
|
||||
|
||||
Rueckgabe: (uebersetzte_artikel, usage)
|
||||
Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
|
||||
Caller kann entscheiden, ob retry oder skip.
|
||||
"""
|
||||
if not articles:
|
||||
return [], ClaudeUsage()
|
||||
|
||||
prompt = _build_prompt(articles, output_lang)
|
||||
|
||||
try:
|
||||
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||
except Exception as e:
|
||||
logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
|
||||
return [], ClaudeUsage()
|
||||
|
||||
try:
|
||||
translations = _parse_response(result_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
|
||||
return [], usage
|
||||
|
||||
# Validierung: nur Translations zurueckgeben, deren id wirklich
|
||||
# in der angefragten Batch war
|
||||
requested_ids = {a["id"] for a in articles}
|
||||
valid = [t for t in translations if t["id"] in requested_ids]
|
||||
if len(valid) != len(translations):
|
||||
logger.warning(
|
||||
"Translator: %d von %d Translations referenzieren unbekannte IDs",
|
||||
len(translations) - len(valid), len(translations),
|
||||
)
|
||||
return valid, usage
|
||||
|
||||
|
||||
async def translate_articles(
|
||||
articles: list[dict],
|
||||
output_lang: str = "de",
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
usage_accumulator: UsageAccumulator | None = None,
|
||||
) -> list[dict]:
|
||||
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
||||
|
||||
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
||||
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
||||
wird er uebersprungen (anderer Batches laufen weiter).
|
||||
"""
|
||||
if not articles:
|
||||
return []
|
||||
|
||||
all_translations = []
|
||||
for i in range(0, len(articles), batch_size):
|
||||
batch = articles[i : i + batch_size]
|
||||
translations, usage = await translate_articles_batch(batch, output_lang)
|
||||
if usage_accumulator is not None:
|
||||
usage_accumulator.add(usage)
|
||||
all_translations.extend(translations)
|
||||
logger.info(
|
||||
"Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
|
||||
(i // batch_size) + 1,
|
||||
(len(articles) + batch_size - 1) // batch_size,
|
||||
len(translations), len(batch),
|
||||
usage.cost_usd,
|
||||
)
|
||||
return all_translations
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren