Sprach-aware Keyword-Matching f�r nicht-lateinische Quellen #27
@@ -844,7 +844,9 @@ class AgentOrchestrator:
|
|||||||
try:
|
try:
|
||||||
if incident_type == "adhoc":
|
if incident_type == "adhoc":
|
||||||
_src_cursor = await db.execute(
|
_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,),
|
(tenant_id,),
|
||||||
)
|
)
|
||||||
_src_row = await _src_cursor.fetchone()
|
_src_row = await _src_cursor.fetchone()
|
||||||
@@ -971,7 +973,11 @@ class AgentOrchestrator:
|
|||||||
if pd_kw_usage:
|
if pd_kw_usage:
|
||||||
usage_acc.add(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")
|
logger.info(f"Podcast-Pipeline: {len(articles)} Episoden gefunden")
|
||||||
return articles, None
|
return articles, None
|
||||||
|
|
||||||
@@ -1009,7 +1015,10 @@ class AgentOrchestrator:
|
|||||||
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
|
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
|
||||||
if tg_kw_usage:
|
if tg_kw_usage:
|
||||||
usage_acc.add(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)
|
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")
|
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
||||||
|
|||||||
@@ -61,6 +61,87 @@ def _extract_json_object(text: str):
|
|||||||
return obj
|
return obj
|
||||||
idx = brace + 1
|
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.
|
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
AUSGABESPRACHE: {output_language}
|
||||||
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||||
@@ -192,7 +273,7 @@ LAGE: {title}
|
|||||||
KONTEXT: {description}
|
KONTEXT: {description}
|
||||||
INTERNATIONALE QUELLEN: {international}
|
INTERNATIONALE QUELLEN: {international}
|
||||||
|
|
||||||
FEEDS:
|
FEEDS (Format: Nr. Name (Domain, Sprache) [Kategorie]):
|
||||||
{feed_list}
|
{feed_list}
|
||||||
|
|
||||||
REGELN:
|
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.
|
- QUELLENVIELFALT: Wähle pro Domain maximal 2-3 Feeds. Bevorzuge eine breite Mischung aus verschiedenen Quellen statt vieler Feeds derselben Domain.
|
||||||
|
|
||||||
KEYWORDS-REGELN:
|
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)
|
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
|
||||||
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
|
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
|
||||||
- KEINE Monatsnamen (Januar, Februar, März etc.)
|
- KEINE Monatsnamen (Januar, Februar, März etc.)
|
||||||
- KEINE generischen Wörter (aktuell, news, update etc.)
|
- KEINE generischen Wörter (aktuell, news, update etc.)
|
||||||
- Begriffe in Kleinbuchstaben
|
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
|
||||||
- Sowohl deutsche als auch englische Begriffe wo sinnvoll
|
|
||||||
|
|
||||||
Antworte NUR mit einem JSON-Objekt in diesem Format:
|
Antworte NUR mit einem JSON-Objekt in genau diesem Format:
|
||||||
{{"feeds": [1, 2, 5, 12], "keywords": ["begriff1", "begriff2", "begriff3"]}}"""
|
{{"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.
|
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
|
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.
|
- 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:
|
REGELN:
|
||||||
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
|
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
|
||||||
Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
|
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.
|
- 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,
|
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
|
||||||
keine Phrasen, keine Konjunktionen).
|
keine Phrasen, keine Konjunktionen).
|
||||||
- Alle Begriffe in Kleinbuchstaben.
|
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
|
||||||
- Exakt 5 Begriffspaare.
|
- Exakt 5 Begriffspaare im "pairs"-Array.
|
||||||
|
|
||||||
Antwort NUR als JSON-Array:
|
Antwort NUR als JSON-Objekt, z.B.:
|
||||||
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
|
{{"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.
|
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,
|
description: str,
|
||||||
international: bool,
|
international: bool,
|
||||||
feeds_metadata: list[dict],
|
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.
|
"""Lässt Claude die relevanten Feeds für eine Lage vorauswählen.
|
||||||
|
|
||||||
Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.
|
Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.
|
||||||
|
|
||||||
Returns:
|
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 = []
|
feed_lines = []
|
||||||
for i, feed in enumerate(feeds_metadata, 1):
|
for i, feed in enumerate(feeds_metadata, 1):
|
||||||
|
lang = feed.get("primary_language") or "?"
|
||||||
feed_lines.append(
|
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(
|
prompt = FEED_SELECTION_PROMPT_TEMPLATE.format(
|
||||||
@@ -316,17 +416,25 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
keywords = None
|
keywords_by_lang: dict | None = None
|
||||||
indices = None
|
indices = None
|
||||||
|
|
||||||
# Neues Format: {"feeds": [...], "keywords": [...]}
|
|
||||||
obj = _extract_json_object(result)
|
obj = _extract_json_object(result)
|
||||||
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
||||||
indices = obj["feeds"]
|
indices = obj["feeds"]
|
||||||
raw_keywords = obj.get("keywords", [])
|
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]
|
# Neues Format: {"de": [...], "en": [...], "ja": [...]}
|
||||||
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
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
|
# Fallback: nacktes Array
|
||||||
if indices is None:
|
if indices is None:
|
||||||
@@ -346,12 +454,12 @@ class ResearcherAgent:
|
|||||||
|
|
||||||
if not selected:
|
if not selected:
|
||||||
logger.warning("Feed-Selektion: Keine gültigen Indizes, nutze alle Feeds")
|
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(
|
logger.info(
|
||||||
f"Feed-Selektion: {len(selected)} von {len(feeds_metadata)} Feeds ausgewählt"
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
|
logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
|
||||||
@@ -360,11 +468,14 @@ class ResearcherAgent:
|
|||||||
|
|
||||||
async def extract_dynamic_keywords(
|
async def extract_dynamic_keywords(
|
||||||
self, title: str, recent_headlines: list[str]
|
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.
|
"""Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku.
|
||||||
|
|
||||||
Returns:
|
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:
|
if not recent_headlines:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -378,25 +489,38 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
parsed = _extract_json_array(result)
|
# Neues Format: {"pairs": [...], "extra": {"ja": [...]}}
|
||||||
if not isinstance(parsed, list):
|
obj = _extract_json_object(result)
|
||||||
logger.warning(
|
pairs_raw = None
|
||||||
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
|
extra_raw: dict = {}
|
||||||
_truncate_for_log(result),
|
if isinstance(obj, dict) and isinstance(obj.get("pairs"), list):
|
||||||
)
|
pairs_raw = obj["pairs"]
|
||||||
return None, usage
|
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
|
de_list: list[str] = []
|
||||||
keywords = []
|
en_list: list[str] = []
|
||||||
for entry in parsed:
|
for entry in pairs_raw or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
de = entry.get("de", "").lower().strip()
|
de = str(entry.get("de", "")).lower().strip()
|
||||||
en = entry.get("en", "").lower().strip()
|
en = str(entry.get("en", "")).lower().strip()
|
||||||
if de:
|
if de and de not in de_list:
|
||||||
keywords.append(de)
|
de_list.append(de)
|
||||||
if en and en != de:
|
if en and en not in en_list:
|
||||||
keywords.append(en)
|
en_list.append(en)
|
||||||
|
|
||||||
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
|
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
|
||||||
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
|
# 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"}
|
"the", "and", "for", "with", "ueber", "über", "von", "for"}
|
||||||
for word in (title or "").lower().split():
|
for word in (title or "").lower().split():
|
||||||
w = word.strip(".,;:!?\"\'()[]{}")
|
w = word.strip(".,;:!?\"\'()[]{}")
|
||||||
if len(w) >= 4 and w not in STOPWORDS and w not in keywords:
|
if len(w) >= 4 and w not in STOPWORDS:
|
||||||
keywords.append(w)
|
if w not in en_list:
|
||||||
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
|
en_list.append(w)
|
||||||
|
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
|
||||||
|
|
||||||
if keywords:
|
keywords_by_lang: dict[str, list[str]] = {}
|
||||||
logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}")
|
if de_list:
|
||||||
return keywords if keywords else None, usage
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
|
|||||||
@@ -8,10 +8,25 @@ from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
|||||||
from source_rules import _extract_domain
|
from source_rules import _extract_domain
|
||||||
from feeds.transcript_extractors._common import html_to_text
|
from feeds.transcript_extractors._common import html_to_text
|
||||||
from services.post_refresh_qc import normalize_german_umlauts
|
from services.post_refresh_qc import normalize_german_umlauts
|
||||||
|
from agents.researcher import keywords_for_language, flatten_keywords
|
||||||
|
|
||||||
logger = logging.getLogger("osint.rss")
|
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:
|
class RSSParser:
|
||||||
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
|
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
|
||||||
|
|
||||||
@@ -28,27 +43,31 @@ class RSSParser:
|
|||||||
cleaned = [w for w in words if not w.isdigit()]
|
cleaned = [w for w in words if not w.isdigit()]
|
||||||
return cleaned if cleaned else words
|
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.
|
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_term: Suchbegriff
|
search_term: Suchbegriff
|
||||||
international: Wenn False, nur Feeds in der Org-Sprache + Behoerden (keine internationalen)
|
international: Wenn False, nur Feeds in der Org-Sprache + Behoerden (keine internationalen)
|
||||||
tenant_id: Optionale Org-ID fuer tenant-spezifische Quellen
|
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 = []
|
all_articles = []
|
||||||
if keywords:
|
if keywords:
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
logger.info(f"RSS-Suche mit Claude-Keywords (Sprachen): "
|
||||||
logger.info(f"RSS-Suche mit Claude-Keywords: {search_words}")
|
f"{ {k: len(v) for k, v in keywords.items()} if isinstance(keywords, dict) else len(keywords) }")
|
||||||
|
fallback_words = None
|
||||||
else:
|
else:
|
||||||
search_words = [
|
fallback_words = self._fallback_search_words(search_term)
|
||||||
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)
|
|
||||||
|
|
||||||
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
||||||
|
|
||||||
@@ -74,7 +93,13 @@ class RSSParser:
|
|||||||
tasks = []
|
tasks = []
|
||||||
for category in categories:
|
for category in categories:
|
||||||
for feed_config in rss_feeds.get(category, []):
|
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)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -89,30 +114,34 @@ class RSSParser:
|
|||||||
all_articles = self._apply_domain_cap(all_articles)
|
all_articles = self._apply_domain_cap(all_articles)
|
||||||
return 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).
|
"""Durchsucht nur die übergebenen Feeds (vorselektiert durch Claude).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_term: Suchbegriff
|
search_term: Suchbegriff
|
||||||
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"}
|
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"} und idealerweise "primary_language"
|
||||||
keywords: Optionale Claude-generierte Keywords (bevorzugt gegenüber Title-Split)
|
keywords: Sprach-Dict {iso_lang: [keyword, ...]} oder flache Liste (Backward).
|
||||||
"""
|
"""
|
||||||
all_articles = []
|
all_articles = []
|
||||||
if keywords:
|
if keywords:
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
if isinstance(keywords, dict):
|
||||||
logger.info(f"RSS-Selektiv mit Claude-Keywords: {search_words}")
|
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:
|
else:
|
||||||
search_words = [
|
fallback_words = self._fallback_search_words(search_term)
|
||||||
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)
|
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for feed_config in selected_feeds:
|
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)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -166,11 +195,11 @@ class RSSParser:
|
|||||||
text = f"{title} {summary}".lower()
|
text = f"{title} {summary}".lower()
|
||||||
|
|
||||||
# Adaptive Match-Schwelle:
|
# Adaptive Match-Schwelle:
|
||||||
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
|
# - Bei mindestens einem spezifischen Keyword (Latin ≥7 Zeichen oder
|
||||||
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
|
# CJK/Arabisch/Hebräisch/Kyrillisch ≥3 Zeichen) im Text reicht 1 Treffer.
|
||||||
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
|
# Damit matched z.B. "自衛隊" (3 Kanji) wie "buckelwal" (9 Zeichen).
|
||||||
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
|
# - 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:
|
if specific_in_text:
|
||||||
min_matches = 1
|
min_matches = 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -61,37 +61,49 @@ class TelegramParser:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def search_channels(self, search_term: str, tenant_id: int = 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.
|
"""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).
|
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
|
||||||
"""
|
"""
|
||||||
|
from agents.researcher import keywords_for_language
|
||||||
|
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
|
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
|
||||||
return []
|
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)
|
channels = await self._get_telegram_channels(tenant_id, channel_ids=channel_ids)
|
||||||
if not channels:
|
if not channels:
|
||||||
logger.info("Keine Telegram-Kanaele konfiguriert")
|
logger.info("Keine Telegram-Kanaele konfiguriert")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Suchwoerter vorbereiten
|
# Fallback-Suchwoerter wenn keine Keywords da sind
|
||||||
if keywords:
|
fallback_words: list[str] | None = None
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
if not keywords:
|
||||||
else:
|
fallback_words = [
|
||||||
search_words = [
|
|
||||||
w for w in search_term.lower().split()
|
w for w in search_term.lower().split()
|
||||||
if w not in STOP_WORDS and len(w) >= 3
|
if w not in STOP_WORDS and len(w) >= 3
|
||||||
]
|
]
|
||||||
if not search_words:
|
if not fallback_words:
|
||||||
search_words = search_term.lower().split()[:2]
|
fallback_words = search_term.lower().split()[:2]
|
||||||
|
|
||||||
# Kanaele parallel abrufen
|
# Kanaele parallel abrufen
|
||||||
tasks = []
|
tasks = []
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
channel_id = ch["url"] or ch["name"]
|
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))
|
tasks.append(self._fetch_channel(client, channel_id, search_words))
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -115,7 +127,7 @@ class TelegramParser:
|
|||||||
if channel_ids and len(channel_ids) > 0:
|
if channel_ids and len(channel_ids) > 0:
|
||||||
placeholders = ",".join("?" for _ in channel_ids)
|
placeholders = ",".join("?" for _ in channel_ids)
|
||||||
cursor = await db.execute(
|
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'
|
WHERE source_type = 'telegram_channel'
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
AND id IN ({placeholders})""",
|
AND id IN ({placeholders})""",
|
||||||
@@ -123,7 +135,7 @@ class TelegramParser:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
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'
|
WHERE source_type = 'telegram_channel'
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
||||||
|
|||||||
@@ -649,14 +649,16 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
|
|||||||
try:
|
try:
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
cursor = await db.execute(
|
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' "
|
"WHERE source_type = ? AND status = 'active' "
|
||||||
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
(source_type, tenant_id),
|
(source_type, tenant_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
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'",
|
"WHERE source_type = ? AND status = 'active'",
|
||||||
(source_type,),
|
(source_type,),
|
||||||
)
|
)
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren