diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index a81efc0..bccb289 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -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") diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 64db182..bf7f046 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -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}") diff --git a/src/feeds/rss_parser.py b/src/feeds/rss_parser.py index 071ae0d..6cd5478 100644 --- a/src/feeds/rss_parser.py +++ b/src/feeds/rss_parser.py @@ -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: diff --git a/src/feeds/telegram_parser.py b/src/feeds/telegram_parser.py index bf51817..e657766 100644 --- a/src/feeds/telegram_parser.py +++ b/src/feeds/telegram_parser.py @@ -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 = ?)""", diff --git a/src/source_rules.py b/src/source_rules.py index e66f638..a817d49 100644 --- a/src/source_rules.py +++ b/src/source_rules.py @@ -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,), )