From b4898614c4e9d15b6fd229857fbebde8e4ec7115 Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Thu, 21 May 2026 01:43:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(topic-filter):=20Pre-Topic-Headline-=C3=9C?= =?UTF-8?q?bersetzung=20f=C3=BCr=20fremdsprachige=20Quellen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/agents/analyzer.py | 10 ++- src/agents/orchestrator.py | 19 +++++ src/agents/translator.py | 153 +++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index 88db2be..f40ab73 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -258,7 +258,8 @@ REGELN: - Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln. - Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt. - Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung. -- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer. +- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel. +- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant. Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung: {{"relevant_ids": [1, 3, 7]}}""" @@ -526,10 +527,17 @@ class AnalyzerAgent: headline = article.get("headline_de") or article.get("headline", "") source = article.get("source", "Unbekannt") content = article.get("content_de") or article.get("content_original") or "" + # Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator) + headline_en = article.get("headline_en_for_topic") + content_en = article.get("content_en_for_topic") lines.append(f"[{i}] Quelle: {source}") lines.append(f" Überschrift: {headline}") + if headline_en and headline_en.strip().lower() != (headline or "").strip().lower(): + lines.append(f" Übersetzung: {headline_en}") if content: lines.append(f" Inhalt: {content[:400]}") + if content_en and content_en.strip().lower() != (content or "")[:len(content_en)].strip().lower(): + lines.append(f" Inhalt (EN): {content_en[:400]}") articles_text = "\n".join(lines) prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format( diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index bccb289..e1191af 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -1139,6 +1139,25 @@ class AgentOrchestrator: await _pipe_start("relevance") _candidates_before_topic = len(new_candidates) + # --- Pre-Topic-Übersetzung: fremdsprachige Headlines ins Englische --- + # Damit der nachgelagerte Topic-Filter (Haiku) auch CJK/Arabisch/ + # Hebräisch/Kyrillisch-Headlines fair beurteilen kann statt sie aus + # Sicherheit zu verwerfen. + if new_candidates: + try: + from agents.translator import translate_headlines_for_topic_filter + _pt_count, _pt_usage = await translate_headlines_for_topic_filter(new_candidates) + if _pt_usage: + usage_acc.add(_pt_usage) + if _pt_count: + logger.info( + f"Pre-Topic-Translate: {_pt_count} fremdsprachige Headlines übersetzt" + ) + except Exception as e: + logger.warning( + f"Pre-Topic-Translate fehlgeschlagen (Pipeline laeuft weiter): {e}" + ) + # --- Semantischer Topic-Filter (Haiku) --- # Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema # der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten. diff --git a/src/agents/translator.py b/src/agents/translator.py index 6225d87..2564f40 100644 --- a/src/agents/translator.py +++ b/src/agents/translator.py @@ -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": , "h": , "c": }}. +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", -- 2.49.1