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:
@@ -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}")
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren