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

@@ -844,7 +844,9 @@ class AgentOrchestrator:
try:
if incident_type == "adhoc":
_src_cursor = await db.execute(
"SELECT COUNT(*) AS cnt FROM sources WHERE tenant_id = ? AND status = 'active'",
"SELECT COUNT(*) AS cnt FROM sources "
"WHERE status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
(tenant_id,),
)
_src_row = await _src_cursor.fetchone()
@@ -971,7 +973,11 @@ class AgentOrchestrator:
if pd_kw_usage:
usage_acc.add(pd_kw_usage)
articles = await pd_parser.search_feeds_selective(title, podcast_feeds, keywords=pd_keywords)
# Podcast-Parser erwartet (noch) eine flache Liste – Podcasts sind
# primaer deutschsprachig, daher reicht das gemeinsame Flatten.
from agents.researcher import flatten_keywords
pd_keywords_flat = flatten_keywords(pd_keywords)
articles = await pd_parser.search_feeds_selective(title, podcast_feeds, keywords=pd_keywords_flat or None)
logger.info(f"Podcast-Pipeline: {len(articles)} Episoden gefunden")
return articles, None
@@ -1009,7 +1015,10 @@ class AgentOrchestrator:
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
if tg_kw_usage:
usage_acc.add(tg_kw_usage)
logger.info(f"Telegram-Keywords: {tg_keywords}")
if isinstance(tg_keywords, dict):
logger.info(f"Telegram-Keywords (Sprachen): { {k: len(v) for k, v in tg_keywords.items()} }")
else:
logger.info(f"Telegram-Keywords: {tg_keywords}")
articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=tg_keywords, channel_ids=selected_ids)
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")

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}")

Datei anzeigen

@@ -8,10 +8,25 @@ from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
from feeds.transcript_extractors._common import html_to_text
from services.post_refresh_qc import normalize_german_umlauts
from agents.researcher import keywords_for_language, flatten_keywords
logger = logging.getLogger("osint.rss")
def _is_specific_word(w: str) -> bool:
"""Spezifisches Keyword = 1-Treffer reicht für Match.
- Lateinisch: ab 7 Zeichen (alte Heuristik).
- Nicht-ASCII (CJK, Arabisch, Hebräisch, Kyrillisch etc.): ab 3 Zeichen.
Beispiel: '自衛隊' (3 Kanji) oder 'путин' (5 Kyrillisch) sind spezifisch genug.
"""
if not w:
return False
if any(ord(c) > 127 for c in w):
return len(w) >= 3
return len(w) >= 7
class RSSParser:
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
@@ -28,27 +43,31 @@ class RSSParser:
cleaned = [w for w in words if not w.isdigit()]
return cleaned if cleaned else words
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None, user_id: int = None) -> list[dict]:
def _fallback_search_words(self, search_term: str) -> list[str]:
words = [
w for w in search_term.lower().split()
if w not in self.STOP_WORDS and len(w) >= 3
]
if not words:
words = search_term.lower().split()[:2]
return self._clean_search_words(words)
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: dict | list | None = None, user_id: int = None) -> list[dict]:
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
Args:
search_term: Suchbegriff
international: Wenn False, nur Feeds in der Org-Sprache + Behoerden (keine internationalen)
tenant_id: Optionale Org-ID fuer tenant-spezifische Quellen
keywords: Optionale Claude-generierte Keywords (bevorzugt gegenüber Title-Split)
keywords: Sprach-Dict {iso_lang: [keyword, ...]} oder flache Liste (Backward).
"""
all_articles = []
if keywords:
search_words = [w.lower().strip() for w in keywords if w.strip()]
logger.info(f"RSS-Suche mit Claude-Keywords: {search_words}")
logger.info(f"RSS-Suche mit Claude-Keywords (Sprachen): "
f"{ {k: len(v) for k, v in keywords.items()} if isinstance(keywords, dict) else len(keywords) }")
fallback_words = None
else:
search_words = [
w for w in search_term.lower().split()
if w not in self.STOP_WORDS and len(w) >= 3
]
if not search_words:
search_words = search_term.lower().split()[:2]
search_words = self._clean_search_words(search_words)
fallback_words = self._fallback_search_words(search_term)
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
@@ -74,7 +93,13 @@ class RSSParser:
tasks = []
for category in categories:
for feed_config in rss_feeds.get(category, []):
tasks.append(self._fetch_feed(feed_config, search_words))
feed_lang = feed_config.get("primary_language")
if keywords:
words = keywords_for_language(keywords, feed_lang)
words = [w.lower() for w in words]
else:
words = fallback_words
tasks.append(self._fetch_feed(feed_config, words))
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -89,30 +114,34 @@ class RSSParser:
all_articles = self._apply_domain_cap(all_articles)
return all_articles
async def search_feeds_selective(self, search_term: str, selected_feeds: list[dict], keywords: list[str] | None = None) -> list[dict]:
async def search_feeds_selective(self, search_term: str, selected_feeds: list[dict], keywords: dict | list | None = None) -> list[dict]:
"""Durchsucht nur die übergebenen Feeds (vorselektiert durch Claude).
Args:
search_term: Suchbegriff
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"}
keywords: Optionale Claude-generierte Keywords (bevorzugt gegenüber Title-Split)
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"} und idealerweise "primary_language"
keywords: Sprach-Dict {iso_lang: [keyword, ...]} oder flache Liste (Backward).
"""
all_articles = []
if keywords:
search_words = [w.lower().strip() for w in keywords if w.strip()]
logger.info(f"RSS-Selektiv mit Claude-Keywords: {search_words}")
if isinstance(keywords, dict):
logger.info(f"RSS-Selektiv mit Claude-Keywords (Sprachen): "
f"{ {k: len(v) for k, v in keywords.items()} }")
else:
logger.info(f"RSS-Selektiv mit Claude-Keywords (flach): {keywords}")
fallback_words = None
else:
search_words = [
w for w in search_term.lower().split()
if w not in self.STOP_WORDS and len(w) >= 3
]
if not search_words:
search_words = search_term.lower().split()[:2]
search_words = self._clean_search_words(search_words)
fallback_words = self._fallback_search_words(search_term)
tasks = []
for feed_config in selected_feeds:
tasks.append(self._fetch_feed(feed_config, search_words))
feed_lang = feed_config.get("primary_language")
if keywords:
words = keywords_for_language(keywords, feed_lang)
words = [w.lower() for w in words]
else:
words = fallback_words
tasks.append(self._fetch_feed(feed_config, words))
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -166,11 +195,11 @@ class RSSParser:
text = f"{title} {summary}".lower()
# Adaptive Match-Schwelle:
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
# - Bei mindestens einem spezifischen Keyword (Latin ≥7 Zeichen oder
# CJK/Arabisch/Hebräisch/Kyrillisch ≥3 Zeichen) im Text reicht 1 Treffer.
# Damit matched z.B. "自衛隊" (3 Kanji) wie "buckelwal" (9 Zeichen).
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
specific_in_text = any(w in text for w in search_words if len(w) >= 7)
specific_in_text = any(w in text for w in search_words if _is_specific_word(w))
if specific_in_text:
min_matches = 1
else:

Datei anzeigen

@@ -61,37 +61,49 @@ class TelegramParser:
return None
async def search_channels(self, search_term: str, tenant_id: int = None,
keywords: list[str] = None, channel_ids: list[int] = None) -> list[dict]:
keywords: dict | list = None, channel_ids: list[int] = None) -> list[dict]:
"""Liest Nachrichten aus konfigurierten Telegram-Kanaelen.
Args:
keywords: Sprach-Dict {iso_lang: [keyword,...]} oder flache Liste (Backward).
Match nutzt pro Kanal die "en"-Universalbegriffe + die Keywords der
Kanalsprache (primary_language aus sources-Tabelle).
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
"""
from agents.researcher import keywords_for_language
client = await self._get_client()
if not client:
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
return []
# Telegram-Kanaele aus DB laden
# Telegram-Kanaele aus DB laden (inkl. primary_language)
channels = await self._get_telegram_channels(tenant_id, channel_ids=channel_ids)
if not channels:
logger.info("Keine Telegram-Kanaele konfiguriert")
return []
# Suchwoerter vorbereiten
if keywords:
search_words = [w.lower().strip() for w in keywords if w.strip()]
else:
search_words = [
# Fallback-Suchwoerter wenn keine Keywords da sind
fallback_words: list[str] | None = None
if not keywords:
fallback_words = [
w for w in search_term.lower().split()
if w not in STOP_WORDS and len(w) >= 3
]
if not search_words:
search_words = search_term.lower().split()[:2]
if not fallback_words:
fallback_words = search_term.lower().split()[:2]
# Kanaele parallel abrufen
tasks = []
for ch in channels:
channel_id = ch["url"] or ch["name"]
channel_lang = ch.get("primary_language")
if keywords:
search_words = keywords_for_language(keywords, channel_lang)
search_words = [w.lower() for w in search_words]
else:
search_words = fallback_words or []
tasks.append(self._fetch_channel(client, channel_id, search_words))
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -115,7 +127,7 @@ class TelegramParser:
if channel_ids and len(channel_ids) > 0:
placeholders = ",".join("?" for _ in channel_ids)
cursor = await db.execute(
f"""SELECT id, name, url, category, notes FROM sources
f"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'telegram_channel'
AND status = 'active'
AND id IN ({placeholders})""",
@@ -123,7 +135,7 @@ class TelegramParser:
)
else:
cursor = await db.execute(
"""SELECT id, name, url, category, notes FROM sources
"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'telegram_channel'
AND status = 'active'
AND (tenant_id IS NULL OR tenant_id = ?)""",

Datei anzeigen

@@ -649,14 +649,16 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
try:
if tenant_id:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"SELECT name, url, domain, category, notes, primary_language, "
"COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
(source_type, tenant_id),
)
else:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"SELECT name, url, domain, category, notes, primary_language, "
"COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active'",
(source_type,),
)