From 7734eefd35be1b36e20f84e9b3bfb4e37d78f4a7 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 7 Mar 2026 23:12:17 +0100 Subject: [PATCH] Dynamische Keyword-Extraktion fuer RSS-Filterung + min_matches-Fix - researcher.py: Neuer dedizierter Haiku-Call extract_dynamic_keywords() analysiert die letzten 30 Headlines und generiert 5 DE+EN Begriffspaare - orchestrator.py: Dynamische Keywords vor Feed-Selektion aus DB-Headlines - rss_parser.py: min_matches auf max 2 gedeckelt (vorher n/2, bei 10 Keywords = 5) - analyzer.py: Fettdruck-Anweisungen entfernt Vorher: 0 RSS-Treffer (min_matches=5 unerreichbar) Nachher: 22 RSS-Treffer (Tagesschau 11, Al Jazeera 5, BBC 4, NYT 2) Co-Authored-By: Claude Opus 4.6 --- src/agents/analyzer.py | 6 +-- src/agents/orchestrator.py | 22 +++++++++-- src/agents/researcher.py | 78 ++++++++++++++++++++++++++++++++++++++ src/feeds/rss_parser.py | 2 +- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index cdc99b2..04f86d7 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -90,7 +90,7 @@ REGELN: - Nummeriere die Quellen fortlaufend ab [1] - Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...") - Markdown-Überschriften (##) für die Abschnitte verwenden -- Fettdruck (**) für Schlüsselbegriffe erlaubt +- KEIN Fettdruck (**) verwenden Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern: - "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc. @@ -126,7 +126,7 @@ AUFTRAG: STRUKTUR: - Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität -- Neue Entwicklungen mit **Fettdruck** hervorheben +- KEIN Fettdruck (**) verwenden REGELN: - Neutral und sachlich - keine Wertungen oder Spekulationen @@ -173,7 +173,7 @@ Aktualisiere das Briefing (max. 800 Wörter) mit den neuen Erkenntnissen. Behalt REGELN: - Bisherige gesicherte Fakten beibehalten -- Neue Erkenntnisse einarbeiten und mit **Fettdruck** hervorheben +- Neue Erkenntnisse einarbeiten - Veraltete Informationen aktualisieren - Quellen immer mit [Nr] referenzieren - Das sources-Array muss ALLE Quellen enthalten (bisherige + neue) diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index a8c1d60..ebc1809 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -568,7 +568,7 @@ class AgentOrchestrator: # Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen async def _rss_pipeline(): - """RSS-Feed-Suche (Feed-Selektion + Parsing).""" + """RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing).""" if incident_type != "adhoc": logger.info("Recherche-Modus: RSS-Feeds übersprungen") return [], None @@ -579,13 +579,29 @@ class AgentOrchestrator: from source_rules import get_feeds_with_metadata all_feeds = await get_feeds_with_metadata(tenant_id=tenant_id) + # Dynamische Keywords aus den letzten Headlines extrahieren + cursor_hl = await db.execute( + """SELECT COALESCE(headline_de, headline) as hl + FROM articles WHERE incident_id = ? + AND COALESCE(headline_de, headline) IS NOT NULL + ORDER BY collected_at DESC LIMIT 30""", + (incident_id,), + ) + recent_headlines = [row["hl"] for row in await cursor_hl.fetchall() if row["hl"]] + dynamic_keywords, kw_usage = await rss_researcher.extract_dynamic_keywords(title, recent_headlines) + if kw_usage: + usage_acc.add(kw_usage) + feed_usage = None - keywords = None + keywords = dynamic_keywords # Dynamische Keywords bevorzugen if len(all_feeds) > 20: - selected_feeds, keywords, feed_usage = await rss_researcher.select_relevant_feeds( + selected_feeds, feed_sel_keywords, feed_usage = await rss_researcher.select_relevant_feeds( title, description, international, all_feeds ) logger.info(f"Feed-Selektion: {len(selected_feeds)} von {len(all_feeds)} Feeds ausgewählt") + # Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen + if not keywords: + keywords = feed_sel_keywords articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords) else: articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords) diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 3be95a6..9a28f79 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -114,6 +114,28 @@ Antworte NUR mit einem JSON-Objekt in diesem Format: {{"feeds": [1, 2, 5, 12], "keywords": ["begriff1", "begriff2", "begriff3"]}}""" +KEYWORD_EXTRACTION_PROMPT = """Analysiere diese aktuellen Nachrichten-Headlines und extrahiere die wichtigsten Suchbegriffe fuer RSS-Feed-Filterung. + +THEMA: {title} + +AKTUELLE HEADLINES (die letzten Meldungen zu diesem Thema): +{headlines} + +AUFGABE: +Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema gefunden werden. +Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen. + +REGELN: +- Die ersten 2 Begriffspaare MUESSEN die zentralen Akteure/Laender/Themen sein (z.B. iran, israel, usa) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen +- Die letzten 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure, Schluesselwoerter der aktuellen Phase) +- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter, keine Phrasen) +- Alle Begriffe in Kleinbuchstaben +- Exakt 5 Begriffspaare + +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"}}]""" + + class ResearcherAgent: """Führt OSINT-Recherchen über Claude CLI WebSearch durch.""" @@ -191,6 +213,62 @@ class ResearcherAgent: logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds") return feeds_metadata, None, None + + async def extract_dynamic_keywords( + self, title: str, recent_headlines: list[str] + ) -> tuple[list[str] | None, ClaudeUsage | None]: + """Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku. + + Returns: + (flache Keyword-Liste DE+EN, usage) oder (None, None) bei Fehler + """ + if not recent_headlines: + return None, None + + headlines_text = "\n".join(f"- {h}" for h in recent_headlines[:30]) + prompt = KEYWORD_EXTRACTION_PROMPT.format( + title=title, + headlines=headlines_text, + ) + + try: + result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) + + parsed = None + try: + parsed = json.loads(result) + except json.JSONDecodeError: + match = re.search(r'\[.*\]', result, re.DOTALL) + if match: + try: + parsed = json.loads(match.group()) + except json.JSONDecodeError: + pass + + if not parsed or not isinstance(parsed, list): + logger.warning("Keyword-Extraktion: Kein gueltiges JSON erhalten") + return None, usage + + # Flache Liste: alle DE + EN Begriffe + keywords = [] + for entry in parsed: + 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) + + if keywords: + logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}") + return keywords if keywords else None, usage + + except Exception as e: + logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}") + return None, None + async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True) -> tuple[list[dict], ClaudeUsage | None]: """Sucht nach Informationen zu einem Vorfall.""" from config import OUTPUT_LANGUAGE diff --git a/src/feeds/rss_parser.py b/src/feeds/rss_parser.py index 11f45ed..eaed414 100644 --- a/src/feeds/rss_parser.py +++ b/src/feeds/rss_parser.py @@ -143,7 +143,7 @@ class RSSParser: text = f"{title} {summary}".lower() # Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet) - min_matches = max(1, (len(search_words) + 1) // 2) + min_matches = min(2, max(1, (len(search_words) + 1) // 2)) match_count = sum(1 for word in search_words if word in text) if match_count >= min_matches: