feat(topic-filter): Pre-Topic-Headline-Übersetzung für fremdsprachige Quellen
Der Topic-Filter (Haiku) hat bisher fremdsprachige Headlines (CJK, Arabisch, Hebräisch, Kyrillisch) konservativ verworfen, weil er die Sicherheitsregel "im Zweifel NICHT relevant" auf jeden Text anwandte, den er nicht klar lesen konnte. Bei Lage 96 (Verfassungsänderung Japan) landeten so 79 von 87 Kandidaten im Papierkorb, darunter alle ja-Quellen mit Kanji-Headlines. Lösung: ein eigener kleiner Haiku-Batch-Call vor dem Topic-Filter übersetzt die Headlines (+ erste 240 Zeichen Content) fremdsprachiger Artikel ins Englische und hängt sie als article["headline_en_for_topic"] / "content_en_for_topic" an. Der Topic-Filter zeigt sie zusätzlich zum Original und beurteilt damit ja/zh/ko/ar/he/ru/fa-Artikel fair. - agents/translator.py: neue Funktion translate_headlines_for_topic_filter, unabhängig vom TRANSLATOR_ENABLED-Flag (Pflicht für korrekten Topic-Filter). - agents/analyzer.py: filter_relevant_articles zeigt Übersetzungen mit an; Prompt-Regel erweitert. - agents/orchestrator.py: Aufruf direkt vor dem Topic-Filter-Schritt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -215,6 +215,159 @@ async def translate_articles_batch(
|
||||
return valid, usage
|
||||
|
||||
|
||||
# --- Pre-Topic-Filter: schmale Headline-Übersetzung -----------------------------
|
||||
#
|
||||
# Der Topic-Filter (analyzer.filter_relevant_articles) ist ein Haiku-Call, der pro
|
||||
# Artikel beurteilt, ob er thematisch zur Lage passt. Bei fremdsprachigen Headlines
|
||||
# (CJK/Arabisch/Hebräisch/Kyrillisch) bewertet Haiku konservativ und verwirft sie
|
||||
# häufig, weil er sie nur halb versteht. Damit landeten z.B. die japanischen
|
||||
# Ministeriums-Feeds (MOD, NHK, Asahi) in Lagen mit Japan-Bezug nie in der finalen
|
||||
# Auswahl, obwohl der RSS-Match korrekt griff.
|
||||
#
|
||||
# Diese Funktion übersetzt einen einzelnen Batch-Call alle nicht-lateinischen
|
||||
# Headlines + erste Content-Sätze ins Englische und hängt das Ergebnis als
|
||||
# article["headline_en_for_topic"] / article["content_en_for_topic"] an. Der
|
||||
# Topic-Filter zeigt das dem LLM zusätzlich zum Original.
|
||||
#
|
||||
# WICHTIG: Diese Mini-Übersetzung ist UNABHÄNGIG vom TRANSLATOR_ENABLED-Flag —
|
||||
# sie wird auch dann gemacht, wenn der nachgelagerte Volltext-Translator
|
||||
# deaktiviert ist (Pflicht für korrektes Topic-Filtering, sehr kleine Kosten).
|
||||
|
||||
_TOPIC_TRANSLATE_CONTENT_MAX = 240
|
||||
|
||||
|
||||
def _needs_pretopic_translate(article: dict) -> bool:
|
||||
"""Erkennt fremdsprachige Headlines, die für den Topic-Filter übersetzt
|
||||
werden sollten.
|
||||
|
||||
Heuristik: Headline enthält Non-ASCII-Zeichen, die NICHT in den typischen
|
||||
deutsch/franz./span./port./skand. Latin-1-Erweiterungen liegen.
|
||||
Das sind v.a. CJK (Kanji/Kana/Hangul), Arabisch, Hebräisch, Kyrillisch,
|
||||
Thai, Devanagari etc.
|
||||
"""
|
||||
headline = (article.get("headline_de") or article.get("headline") or "").strip()
|
||||
if not headline:
|
||||
return False
|
||||
for ch in headline:
|
||||
cp = ord(ch)
|
||||
# Bereiche ausschließen, die in Latin-Schrift normal sind:
|
||||
# ASCII (0-127), Latin-1 Supplement (128-255), Latin Extended-A/B (256-591)
|
||||
if cp <= 591:
|
||||
continue
|
||||
# Alles darüber sind fremde Schriftsysteme → übersetzen
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def translate_headlines_for_topic_filter(
|
||||
articles: list[dict],
|
||||
target_lang: str = "en",
|
||||
) -> tuple[int, ClaudeUsage]:
|
||||
"""Übersetzt die Headlines fremdsprachiger Artikel ins Englische, damit der
|
||||
nachgelagerte Topic-Filter (Haiku) sie zuverlässig beurteilen kann.
|
||||
|
||||
Setzt direkt auf den Artikel-Dicts:
|
||||
article["headline_en_for_topic"]: str | None
|
||||
article["content_en_for_topic"]: str | None
|
||||
|
||||
Returns:
|
||||
(anzahl_übersetzt, ClaudeUsage)
|
||||
"""
|
||||
if not articles:
|
||||
return 0, ClaudeUsage()
|
||||
|
||||
candidates = [a for a in articles if _needs_pretopic_translate(a)]
|
||||
if not candidates:
|
||||
return 0, ClaudeUsage()
|
||||
|
||||
# Eindeutige Indizes (auch wenn article kein "id"-Feld hat, weil noch nicht
|
||||
# in der DB): wir nutzen die Position in der gesamten articles-Liste.
|
||||
idx_by_obj = {id(a): i for i, a in enumerate(articles)}
|
||||
|
||||
items = []
|
||||
for a in candidates:
|
||||
idx = idx_by_obj.get(id(a))
|
||||
if idx is None:
|
||||
continue
|
||||
headline = (a.get("headline_de") or a.get("headline") or "").strip()
|
||||
content_src = (a.get("content_de") or a.get("content_original") or "")
|
||||
items.append({
|
||||
"i": idx,
|
||||
"h": headline[:200],
|
||||
"c": content_src[:_TOPIC_TRANSLATE_CONTENT_MAX],
|
||||
})
|
||||
|
||||
if not items:
|
||||
return 0, ClaudeUsage()
|
||||
|
||||
lang_label = {"en": "English", "de": "German"}.get(target_lang, target_lang)
|
||||
prompt = f"""Translate these news headlines and short content snippets to {lang_label}.
|
||||
Keep proper names (people, organizations, places) untouched. Keep it concise; the goal
|
||||
is to let another model judge topical relevance, not to publish.
|
||||
|
||||
Return ONLY a JSON array. Each item: {{"i": <index>, "h": <headline in {lang_label}>, "c": <content snippet in {lang_label}>}}.
|
||||
Keep the same "i" values. No prose, no markdown fences.
|
||||
|
||||
INPUT:
|
||||
{json.dumps(items, ensure_ascii=False)}
|
||||
"""
|
||||
|
||||
try:
|
||||
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||
except Exception as e:
|
||||
logger.warning(f"Pre-Topic-Translate Claude-Call fehlgeschlagen: {e}")
|
||||
return 0, ClaudeUsage()
|
||||
|
||||
# Robustes Parsing (Markdown-Codefence + nacktes Array)
|
||||
text = result_text.strip()
|
||||
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:
|
||||
m = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if not m:
|
||||
logger.warning(
|
||||
f"Pre-Topic-Translate: kein JSON-Array in Antwort. Sample: {text[:200]!r}"
|
||||
)
|
||||
return 0, usage
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
data = _extract_complete_objects(text)
|
||||
|
||||
if not isinstance(data, list):
|
||||
logger.warning(
|
||||
f"Pre-Topic-Translate: Antwort ist kein Array ({type(data).__name__})"
|
||||
)
|
||||
return 0, usage
|
||||
|
||||
applied = 0
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
idx = entry.get("i")
|
||||
if not isinstance(idx, int) or not (0 <= idx < len(articles)):
|
||||
try:
|
||||
idx = int(idx)
|
||||
if not (0 <= idx < len(articles)):
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
h = (entry.get("h") or "").strip() or None
|
||||
c = (entry.get("c") or "").strip() or None
|
||||
if h:
|
||||
articles[idx]["headline_en_for_topic"] = h
|
||||
if c:
|
||||
articles[idx]["content_en_for_topic"] = c
|
||||
if h or c:
|
||||
applied += 1
|
||||
|
||||
return applied, usage
|
||||
|
||||
|
||||
async def translate_articles(
|
||||
articles: list[dict],
|
||||
output_lang: str = "de",
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren