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",