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:
@@ -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.
|
- 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.
|
- 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.
|
- 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:
|
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
||||||
{{"relevant_ids": [1, 3, 7]}}"""
|
{{"relevant_ids": [1, 3, 7]}}"""
|
||||||
@@ -526,10 +527,17 @@ class AnalyzerAgent:
|
|||||||
headline = article.get("headline_de") or article.get("headline", "")
|
headline = article.get("headline_de") or article.get("headline", "")
|
||||||
source = article.get("source", "Unbekannt")
|
source = article.get("source", "Unbekannt")
|
||||||
content = article.get("content_de") or article.get("content_original") or ""
|
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"[{i}] Quelle: {source}")
|
||||||
lines.append(f" Überschrift: {headline}")
|
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:
|
if content:
|
||||||
lines.append(f" Inhalt: {content[:400]}")
|
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)
|
articles_text = "\n".join(lines)
|
||||||
|
|
||||||
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
|
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
|
||||||
|
|||||||
@@ -1139,6 +1139,25 @@ class AgentOrchestrator:
|
|||||||
await _pipe_start("relevance")
|
await _pipe_start("relevance")
|
||||||
_candidates_before_topic = len(new_candidates)
|
_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) ---
|
# --- Semantischer Topic-Filter (Haiku) ---
|
||||||
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
|
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
|
||||||
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
|
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
|
||||||
|
|||||||
@@ -215,6 +215,159 @@ async def translate_articles_batch(
|
|||||||
return valid, usage
|
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(
|
async def translate_articles(
|
||||||
articles: list[dict],
|
articles: list[dict],
|
||||||
output_lang: str = "de",
|
output_lang: str = "de",
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren