feat(rss/telegram): sprach-aware Keyword-Matching für nicht-lateinische Quellen

Bisher generierte Haiku Keywords nur in DE/EN/Romaji. Japanische RSS-Feeds
(z.B. MOD-GNews mit "防衛省・自衛隊の宇宙政策") matchten daher nie, weil
"jieitai" ≠ "自衛隊". Arabische/persische Telegram-Channels matchten nur
durch Zufall (lateinische Eigennamen in Hashtags/URLs).

Drei zusammenhängende Änderungen:

1. get_feeds_with_metadata liefert primary_language pro Feed mit.
2. FEED_SELECTION_PROMPT_TEMPLATE und KEYWORD_EXTRACTION_PROMPT verlangen
   sprach-gruppierte Keywords ({de:[...], en:[...], ja:[...], ru:[...], ...}).
   "en" enthält lateinische Eigennamen (universell). Andere Sprachen werden
   nur gegen Feeds derselben Sprache gematcht.
3. RSS- und Telegram-Parser kombinieren pro Feed/Channel die "en"-Universalbegriffe
   mit den Keywords der Quellsprache. Die Spezifik-Schwelle (1-Treffer-Match)
   greift jetzt auch ab 3 Zeichen bei Non-ASCII (CJK, Arabisch, Kyrillisch).

Backward-kompatibel: flache Keyword-Listen werden weiter akzeptiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
2026-05-21 00:29:49 +02:00
Ursprung 168fbc3987
Commit 3345743aa5
5 geänderte Dateien mit 288 neuen und 91 gelöschten Zeilen

Datei anzeigen

@@ -61,6 +61,87 @@ def _extract_json_object(text: str):
return obj
idx = brace + 1
def _normalize_keywords_dict(raw: dict) -> dict | None:
"""Normalisiert ein {iso_lang: [keywords]}-Dict aus Haiku-Output.
Wir wenden .lower() global an (Python case-folding lässt CJK unverändert und
lowercased kyrillisch/arabisch/hebräisch sinnvoll), damit der Match später
konsistent gegen den ebenfalls lowercased Headline-Text läuft.
Entfernt leere Strings und Duplikate. Gibt None zurück, wenn das Ergebnis leer ist.
"""
out: dict[str, list[str]] = {}
for lang, kws in raw.items():
if not isinstance(lang, str) or not isinstance(kws, list):
continue
lang_key = lang.lower().strip()
clean: list[str] = []
seen: set[str] = set()
for k in kws:
s = str(k).strip().lower()
if not s or s in seen:
continue
seen.add(s)
clean.append(s)
if clean:
out[lang_key] = clean
return out or None
def flatten_keywords(keywords_by_lang: dict | list | None) -> list[str]:
"""Bequeme Flachsicht aller Keywords (für Logging, Web-Source-Selektion etc.).
Akzeptiert auch die alte flache Liste, damit Aufrufer schrittweise migrieren können.
"""
if not keywords_by_lang:
return []
if isinstance(keywords_by_lang, list):
return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
flat: list[str] = []
seen: set[str] = set()
for kws in keywords_by_lang.values():
if not isinstance(kws, list):
continue
for k in kws:
s = str(k).strip()
if not s or s in seen:
continue
seen.add(s)
flat.append(s)
return flat
def keywords_for_language(keywords_by_lang: dict | list | None, lang: str | None) -> list[str]:
"""Liefert die für eine konkrete Feed-/Channel-Sprache anwendbaren Keywords.
- Universelle "en"-Keywords (lateinische Eigennamen) immer mitgeben.
- Plus die Keywords der Feed-Sprache, falls vorhanden.
- Für unbekannte/None-Sprachen: alle Keywords (flach), damit kein Feed leer ausgeht.
- Akzeptiert auch alte flache Liste -> wird unverändert zurückgegeben.
"""
if not keywords_by_lang:
return []
if isinstance(keywords_by_lang, list):
return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
if not lang:
return flatten_keywords(keywords_by_lang)
lang_key = lang.lower().strip()
out: list[str] = []
seen: set[str] = set()
for k_lang in ("en", lang_key):
for k in keywords_by_lang.get(k_lang, []) or []:
s = str(k).strip()
if not s or s in seen:
continue
seen.add(s)
out.append(s)
# Wenn weder "en" noch lang_key Treffer haben (z.B. Haiku-Schema-Mismatch):
# auf die universelle Flachsicht zurückfallen, damit der Feed nicht leer matched.
if not out:
return flatten_keywords(keywords_by_lang)
return out
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
@@ -192,7 +273,7 @@ LAGE: {title}
KONTEXT: {description}
INTERNATIONALE QUELLEN: {international}
FEEDS:
FEEDS (Format: Nr. Name (Domain, Sprache) [Kategorie]):
{feed_list}
REGELN:
@@ -203,16 +284,23 @@ REGELN:
- QUELLENVIELFALT: Wähle pro Domain maximal 2-3 Feeds. Bevorzuge eine breite Mischung aus verschiedenen Quellen statt vieler Feeds derselben Domain.
KEYWORDS-REGELN:
- Generiere 5-10 thematisch relevante Suchbegriffe für das RSS-Matching
- Keywords werden nach Sprache GRUPPIERT zurückgegeben (siehe Format unten).
- "en" enthält universelle Begriffe (Eigennamen, Akronyme, lateinisch geschriebene Marken/Personen),
die in JEDER Sprache vorkommen (z.B. "iran", "trump", "takaichi", "sdf").
- Für JEDE Sprache, in der ausgewählte Feeds publizieren (z.B. "ja", "ru", "ar", "zh", "ko", "fa",
"he", "de"), MUSS zusätzlich eine Liste mit 3-8 Suchbegriffen in der jeweiligen ORIGINALSCHRIFT
generiert werden. Beispiel Japan: "ja": ["自衛隊", "憲法改正", "改憲", "9条", "防衛省"].
Beispiel Russland: "ru": ["украина", "путин", "москва", "санкции"].
- Wenn die Lage rein deutsch oder englisch ist und keine fremdsprachigen Feeds gewählt werden,
reichen "de" und/oder "en".
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
- KEINE Monatsnamen (Januar, Februar, März etc.)
- KEINE generischen Wörter (aktuell, news, update etc.)
- Begriffe in Kleinbuchstaben
- Sowohl deutsche als auch englische Begriffe wo sinnvoll
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
Antworte NUR mit einem JSON-Objekt in diesem Format:
{{"feeds": [1, 2, 5, 12], "keywords": ["begriff1", "begriff2", "begriff3"]}}"""
Antworte NUR mit einem JSON-Objekt in genau diesem Format:
{{"feeds": [1, 2, 5, 12], "keywords": {{"de": ["..."], "en": ["..."], "ja": ["..."]}}}}"""
KEYWORD_EXTRACTION_PROMPT = """Analysiere diese aktuellen Nachrichten-Headlines und extrahiere die wichtigsten Suchbegriffe fuer RSS-Feed-Filterung.
@@ -227,6 +315,11 @@ Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen
- bei spezifischen Begriffen (Eigennamen, lange Begriffe ab 7 Zeichen) reicht 1 Treffer.
Wenn das Thema einen klaren Länderbezug zu einem nicht-lateinischen Sprachraum hat (z.B. Japan,
China, Korea, Russland, Iran, Israel, arabische Welt), GIB ZUSAETZLICH ein Feld "extra" mit
schrift-spezifischen Keywords pro Sprache zurück (siehe Format unten). Diese matchen dann die
Original-Headlines in den jeweiligen Feeds.
REGELN:
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
@@ -238,11 +331,13 @@ REGELN:
- Wenn DE und EN identisch sind (Eigennamen), trotzdem das Paar einreichen.
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
keine Phrasen, keine Konjunktionen).
- Alle Begriffe in Kleinbuchstaben.
- Exakt 5 Begriffspaare.
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
- Exakt 5 Begriffspaare im "pairs"-Array.
Antwort NUR als JSON-Array:
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
Antwort NUR als JSON-Objekt, z.B.:
{{"pairs": [{{"de": "japan", "en": "japan"}}, {{"de": "verfassung", "en": "constitution"}}, {{"de": "takaichi", "en": "takaichi"}}, {{"de": "selbstverteidigung", "en": "sdf"}}, {{"de": "pazifismus", "en": "pacifism"}}], "extra": {{"ja": ["自衛隊", "憲法改正", "改憲", "9条", "高市"]}}}}
Wenn kein nicht-lateinischer Sprachraum betroffen ist, lass "extra" weg oder gib `{{}}` zurück."""
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
@@ -291,19 +386,24 @@ class ResearcherAgent:
description: str,
international: bool,
feeds_metadata: list[dict],
) -> tuple[list[dict], list[str] | None, ClaudeUsage | None]:
) -> tuple[list[dict], dict | None, ClaudeUsage | None]:
"""Lässt Claude die relevanten Feeds für eine Lage vorauswählen.
Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.
Returns:
(ausgewählte Feeds, keywords, usage) — Bei Fehler: (alle Feeds, None, None)
(ausgewählte Feeds, keywords_by_lang, usage)
keywords_by_lang ist ein Dict {iso_lang: [keyword, ...]} mit mindestens
den Schlüsseln, für die ausgewählte Feeds publizieren ("en" enthält
universelle/lateinische Begriffe, die in jedem Feed matchen).
Bei Fehler: (alle Feeds, None, usage_or_None).
"""
# Feed-Liste als nummerierte Übersicht formatieren
# Feed-Liste als nummerierte Übersicht formatieren (mit Sprache)
feed_lines = []
for i, feed in enumerate(feeds_metadata, 1):
lang = feed.get("primary_language") or "?"
feed_lines.append(
f"{i}. {feed['name']} ({feed['domain']}) [{feed['category']}]"
f"{i}. {feed['name']} ({feed['domain']}, {lang}) [{feed['category']}]"
)
prompt = FEED_SELECTION_PROMPT_TEMPLATE.format(
@@ -316,17 +416,25 @@ class ResearcherAgent:
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
keywords = None
keywords_by_lang: dict | None = None
indices = None
# Neues Format: {"feeds": [...], "keywords": [...]}
obj = _extract_json_object(result)
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
indices = obj["feeds"]
raw_keywords = obj.get("keywords", [])
if isinstance(raw_keywords, list) and raw_keywords:
keywords = [str(k).lower().strip() for k in raw_keywords if k]
logger.info(f"Feed-Selektion Keywords: {keywords}")
raw_keywords = obj.get("keywords")
# Neues Format: {"de": [...], "en": [...], "ja": [...]}
if isinstance(raw_keywords, dict):
keywords_by_lang = _normalize_keywords_dict(raw_keywords)
# Backward-Format: flache Liste -> als "en" speichern (universell behandelt)
elif isinstance(raw_keywords, list) and raw_keywords:
flat = [str(k).strip() for k in raw_keywords if str(k).strip()]
if flat:
keywords_by_lang = {"en": [w.lower() for w in flat]}
if keywords_by_lang:
logger.info(f"Feed-Selektion Keywords (Sprachen): {keywords_by_lang}")
# Fallback: nacktes Array
if indices is None:
@@ -346,12 +454,12 @@ class ResearcherAgent:
if not selected:
logger.warning("Feed-Selektion: Keine gültigen Indizes, nutze alle Feeds")
return feeds_metadata, keywords, usage
return feeds_metadata, keywords_by_lang, usage
logger.info(
f"Feed-Selektion: {len(selected)} von {len(feeds_metadata)} Feeds ausgewählt"
)
return selected, keywords, usage
return selected, keywords_by_lang, usage
except Exception as e:
logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
@@ -360,11 +468,14 @@ class ResearcherAgent:
async def extract_dynamic_keywords(
self, title: str, recent_headlines: list[str]
) -> tuple[list[str] | None, ClaudeUsage | None]:
) -> tuple[dict | None, ClaudeUsage | None]:
"""Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku.
Returns:
(flache Keyword-Liste DE+EN, usage) oder (None, None) bei Fehler
(keywords_by_lang, usage) oder (None, None) bei Fehler.
keywords_by_lang ist ein Dict {iso_lang: [keyword,...]}, mit mindestens
"de" und "en" gefüllt, optional zusätzlich "ja"/"zh"/"ko"/"ar"/"he"/"fa"/"ru"
bei nicht-lateinischen Sprachräumen.
"""
if not recent_headlines:
return None, None
@@ -378,25 +489,38 @@ class ResearcherAgent:
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
parsed = _extract_json_array(result)
if not isinstance(parsed, list):
logger.warning(
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
_truncate_for_log(result),
)
return None, usage
# Neues Format: {"pairs": [...], "extra": {"ja": [...]}}
obj = _extract_json_object(result)
pairs_raw = None
extra_raw: dict = {}
if isinstance(obj, dict) and isinstance(obj.get("pairs"), list):
pairs_raw = obj["pairs"]
extra = obj.get("extra")
if isinstance(extra, dict):
extra_raw = extra
else:
# Backward: nacktes Array von {de,en}-Paaren
arr = _extract_json_array(result)
if isinstance(arr, list):
pairs_raw = arr
else:
logger.warning(
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
_truncate_for_log(result),
)
return None, usage
# Flache Liste: alle DE + EN Begriffe
keywords = []
for entry in parsed:
de_list: list[str] = []
en_list: list[str] = []
for entry in pairs_raw or []:
if not isinstance(entry, dict):
continue
de = entry.get("de", "").lower().strip()
en = entry.get("en", "").lower().strip()
if de:
keywords.append(de)
if en and en != de:
keywords.append(en)
de = str(entry.get("de", "")).lower().strip()
en = str(entry.get("en", "")).lower().strip()
if de and de not in de_list:
de_list.append(de)
if en and en not in en_list:
en_list.append(en)
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
@@ -405,13 +529,34 @@ class ResearcherAgent:
"the", "and", "for", "with", "ueber", "über", "von", "for"}
for word in (title or "").lower().split():
w = word.strip(".,;:!?\"\'()[]{}")
if len(w) >= 4 and w not in STOPWORDS and w not in keywords:
keywords.append(w)
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
if len(w) >= 4 and w not in STOPWORDS:
if w not in en_list:
en_list.append(w)
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
if keywords:
logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}")
return keywords if keywords else None, usage
keywords_by_lang: dict[str, list[str]] = {}
if de_list:
keywords_by_lang["de"] = de_list
if en_list:
keywords_by_lang["en"] = en_list
# Extra-Sprachen mit übernehmen
extra_norm = _normalize_keywords_dict(extra_raw) if extra_raw else None
if extra_norm:
for lang, kws in extra_norm.items():
keywords_by_lang.setdefault(lang, [])
for k in kws:
if k not in keywords_by_lang[lang]:
keywords_by_lang[lang].append(k)
if not keywords_by_lang:
return None, usage
logger.info(
"Dynamische Keywords (Sprachen): %s",
{k: len(v) for k, v in keywords_by_lang.items()},
)
return keywords_by_lang, usage
except Exception as e:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")