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 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-07 23:12:17 +01:00
Ursprung 29dc457ceb
Commit 7734eefd35
4 geänderte Dateien mit 101 neuen und 7 gelöschten Zeilen

Datei anzeigen

@@ -90,7 +90,7 @@ REGELN:
- Nummeriere die Quellen fortlaufend ab [1] - Nummeriere die Quellen fortlaufend ab [1]
- Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...") - Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...")
- Markdown-Überschriften (##) für die Abschnitte verwenden - Markdown-Überschriften (##) für die Abschnitte verwenden
- Fettdruck (**) für Schlüsselbegriffe erlaubt - KEIN Fettdruck (**) verwenden
Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern: Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc. - "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
@@ -126,7 +126,7 @@ AUFTRAG:
STRUKTUR: STRUKTUR:
- Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität - Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität
- Neue Entwicklungen mit **Fettdruck** hervorheben - KEIN Fettdruck (**) verwenden
REGELN: REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen - Neutral und sachlich - keine Wertungen oder Spekulationen
@@ -173,7 +173,7 @@ Aktualisiere das Briefing (max. 800 Wörter) mit den neuen Erkenntnissen. Behalt
REGELN: REGELN:
- Bisherige gesicherte Fakten beibehalten - Bisherige gesicherte Fakten beibehalten
- Neue Erkenntnisse einarbeiten und mit **Fettdruck** hervorheben - Neue Erkenntnisse einarbeiten
- Veraltete Informationen aktualisieren - Veraltete Informationen aktualisieren
- Quellen immer mit [Nr] referenzieren - Quellen immer mit [Nr] referenzieren
- Das sources-Array muss ALLE Quellen enthalten (bisherige + neue) - Das sources-Array muss ALLE Quellen enthalten (bisherige + neue)

Datei anzeigen

@@ -568,7 +568,7 @@ class AgentOrchestrator:
# Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen # Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen
async def _rss_pipeline(): async def _rss_pipeline():
"""RSS-Feed-Suche (Feed-Selektion + Parsing).""" """RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing)."""
if incident_type != "adhoc": if incident_type != "adhoc":
logger.info("Recherche-Modus: RSS-Feeds übersprungen") logger.info("Recherche-Modus: RSS-Feeds übersprungen")
return [], None return [], None
@@ -579,13 +579,29 @@ class AgentOrchestrator:
from source_rules import get_feeds_with_metadata from source_rules import get_feeds_with_metadata
all_feeds = await get_feeds_with_metadata(tenant_id=tenant_id) 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 feed_usage = None
keywords = None keywords = dynamic_keywords # Dynamische Keywords bevorzugen
if len(all_feeds) > 20: 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 title, description, international, all_feeds
) )
logger.info(f"Feed-Selektion: {len(selected_feeds)} von {len(all_feeds)} Feeds ausgewählt") 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) articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
else: else:
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords) articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords)

Datei anzeigen

@@ -114,6 +114,28 @@ Antworte NUR mit einem JSON-Objekt in diesem Format:
{{"feeds": [1, 2, 5, 12], "keywords": ["begriff1", "begriff2", "begriff3"]}}""" {{"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: class ResearcherAgent:
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch.""" """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") logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
return feeds_metadata, None, None 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]: 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.""" """Sucht nach Informationen zu einem Vorfall."""
from config import OUTPUT_LANGUAGE from config import OUTPUT_LANGUAGE

Datei anzeigen

@@ -143,7 +143,7 @@ class RSSParser:
text = f"{title} {summary}".lower() text = f"{title} {summary}".lower()
# Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet) # 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) match_count = sum(1 for word in search_words if word in text)
if match_count >= min_matches: if match_count >= min_matches: