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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren