255 Zeilen
8.6 KiB
Python
255 Zeilen
8.6 KiB
Python
"""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, TRANSLATOR_ENABLED
|
|
|
|
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 mit einem flachen JSON-Array (kein Wrapper-Objekt!).
|
|
Format genau so:
|
|
[
|
|
{{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
|
|
{{"id": 2, "headline_de": "...", "content_de": "..."}}
|
|
]
|
|
|
|
NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
|
|
Nur das Array, ohne Einleitung, ohne Erklaerung.
|
|
|
|
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)
|
|
|
|
# Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
|
|
if isinstance(data, dict):
|
|
for key in ("translations", "items", "results", "data"):
|
|
if isinstance(data.get(key), list):
|
|
data = data[key]
|
|
break
|
|
else:
|
|
# Einzelnes Objekt? Dann als Liste mit einem Element behandeln
|
|
if "id" in data:
|
|
data = [data]
|
|
else:
|
|
raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
|
|
|
|
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 []
|
|
|
|
if not TRANSLATOR_ENABLED:
|
|
logger.info(
|
|
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
|
|
len(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
|