From 60b8646fe4a1d49e794889b659b6d699173970cb Mon Sep 17 00:00:00 2001 From: claude-dev Date: Tue, 21 Apr 2026 12:01:56 +0000 Subject: [PATCH] Semantischer Topic-Filter gegen off-topic Keyword-Zufallstreffer Neue Artikel passieren jetzt vor DB-Speicherung einen Haiku-Relevanzfilter (AnalyzerAgent.filter_relevant_articles), der Artikel verwirft, die nur auf generische Keywords matchen, aber das Kernthema der Lage nicht inhaltlich behandeln. Bei Parsing-/API-Fehler oder 100%-Rejection: Fallback auf unveraenderte Kandidatenliste. Orchestrator trennt DB-Dedup und INSERT, damit der Filter nur auf neue Kandidaten laeuft (Kostenoptimierung). LATEST_DEVELOPMENTS-Prompt erhaelt zusaetzliche Relevanz-Gate-Regel als zweite Sicherung. Hintergrund: Incident 'Russische Militaerblogger' sammelte bisher Iran-, Nahost- und allgemeine Ukraine-Artikel ein, weil Keyword-Match ab 2 von 8 Begriffen ('iran', 'russland', 'drohne', ...) genuegt. Der semantische Filter verwirft solche Zufallstreffer. --- src/agents/analyzer.py | 94 ++++++++++++++++++++++++++++++++++++++ src/agents/orchestrator.py | 25 +++++++--- 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index 2468524..6af9d78 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -242,9 +242,37 @@ OUTPUT-FORMAT (ausschliesslich, keine Anführungszeichen, kein Code-Fence, JEDE - [DD.MM. HH:MM] Ereignistext neu. {{M}} - [DD.MM. HH:MM] Ereignistext neu mit mehreren Belegen. {{M, M}} - [DD.MM. HH:MM] Ereignistext aus BISHERIGE ENTWICKLUNGEN. {{Quellenname1|URL1, Quellenname2|URL2}} + +RELEVANZ-GATE (vor der Bullet-Erzeugung anwenden): +- Jedes Bullet MUSS einen klaren inhaltlichen Bezug zum SPEZIFISCHEN Kernthema der Lage (siehe LAGE + KONTEXT) haben. Keyword-Überschneidungen reichen NICHT. +- Beispiel: Lautet das Thema "Russische Militärblogger bewerten die Lage der russischen Armee", ist ein allgemeiner Iran-Konflikt- oder Nahost-Bericht NICHT relevant, auch wenn er in den neuen Meldungen auftaucht. Nur Einschätzungen/Aussagen russischer Militärblogger zur russischen Armee gehören rein. +- Im Zweifel: Meldung ignorieren, kein Bullet erzeugen. Lieber weniger Bullets als off-topic. ...""" +TOPIC_FILTER_PROMPT_TEMPLATE = """Du bist ein OSINT-Relevanzfilter. Ein vorgeschalteter Keyword-Prefilter hat diese Artikel für eine Lage durchgelassen — aber Keyword-Treffer allein reichen nicht. Artikel müssen das SPEZIFISCHE KERNTHEMA der Lage inhaltlich behandeln. + +LAGE: {title} +KONTEXT: {description} + +ARTIKEL-KANDIDATEN: +{articles_text} + +AUFGABE: +Entscheide je Artikel, ob er thematisch zur Lage passt, und gib die laufenden Nummern der relevanten Artikel zurück. + +REGELN: +- Relevant = der Artikel behandelt konkret das im Titel + Kontext beschriebene Kernthema. Zentrale Akteure, Handlungen, Aussagen oder Ereignisse des Themas müssen im Artikel erkennbar sein. +- NICHT relevant = Artikel, die nur allgemeine Begriffe aus dem Thema streifen (z.B. "Russland", "Iran", "Krieg", "Drohne"), ohne das Spezifikum der Lage zu behandeln. Allgemeine Kontext-Berichte aus der gleichen Region oder zum gleichen Großkonflikt sind NICHT automatisch relevant. +- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln. +- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt. +- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung. +- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer. + +Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung: +{{"relevant_ids": [1, 3, 7]}}""" + + class AnalyzerAgent: """Analysiert und übersetzt Meldungen über Claude CLI.""" @@ -379,6 +407,72 @@ class AnalyzerAgent: logger.error(f"Inkrementelle Analyse-Fehler: {e}") return None, None + async def filter_relevant_articles( + self, + title: str, + description: str, + articles: list[dict], + ) -> tuple[list[dict], ClaudeUsage | None]: + """Semantischer Topic-Filter (Haiku). + + Nimmt die vom Keyword-Prefilter durchgelassenen Artikel und wirft diejenigen raus, + die zwar auf Keywords matchen, aber das Kernthema der Lage thematisch nicht treffen. + Fällt bei Parsing- oder API-Fehlern auf die unveränderte Liste zurück. + """ + if not articles: + return articles, None + + lines = [] + for i, article in enumerate(articles, 1): + headline = article.get("headline_de") or article.get("headline", "") + source = article.get("source", "Unbekannt") + content = article.get("content_de") or article.get("content_original") or "" + lines.append(f"[{i}] Quelle: {source}") + lines.append(f" Überschrift: {headline}") + if content: + lines.append(f" Inhalt: {content[:400]}") + articles_text = "\n".join(lines) + + prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format( + title=title, + description=description or "Keine weiteren Details", + articles_text=articles_text, + ) + + from config import CLAUDE_MODEL_FAST + try: + result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) + except Exception as e: + logger.warning(f"Topic-Filter-Fehler (behalte alle {len(articles)} Artikel): {e}") + return articles, None + + parsed = self._parse_response(result) + if not parsed or not isinstance(parsed.get("relevant_ids"), list): + logger.warning( + f"Topic-Filter: keine relevant_ids geparst, behalte alle {len(articles)} Artikel" + ) + return articles, usage + + relevant_set = { + i for i in parsed["relevant_ids"] + if isinstance(i, int) and 1 <= i <= len(articles) + } + filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set] + + rejected = len(articles) - len(filtered) + if not filtered and articles: + logger.warning( + f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — " + "möglicherweise zu aggressiv. Behalte Original." + ) + return articles, usage + + logger.info( + f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant " + f"({rejected} verworfen)" + ) + return filtered, usage + async def generate_latest_developments( self, title: str, diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 60ce383..2036a31 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -933,18 +933,15 @@ class AgentOrchestrator: logger.info(f"DB-Dedup: {len(existing_urls)} URLs, {len(existing_headlines)} Headlines im Bestand") - # Neue Artikel speichern und für Analyse tracken - new_count = 0 - new_articles_for_analysis = [] + # --- Dedup gegen Bestand: nur neue (noch nicht gespeicherte) Kandidaten behalten --- + new_candidates = [] for article in unique_results: - # URL-Duplikat gegen DB if article.get("source_url"): norm_url = _normalize_url(article["source_url"]) if norm_url in existing_urls: continue existing_urls.add(norm_url) - # Headline-Duplikat gegen DB headline = article.get("headline", "") if headline and len(headline) > 20: norm_h = _normalize_headline(headline) @@ -953,6 +950,23 @@ class AgentOrchestrator: if norm_h: existing_headlines.add(norm_h) + new_candidates.append(article) + + # --- Semantischer Topic-Filter (Haiku) --- + # Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema + # der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten. + if new_candidates: + _tf_agent = AnalyzerAgent() + new_candidates, _tf_usage = await _tf_agent.filter_relevant_articles( + title, description, new_candidates, + ) + if _tf_usage: + usage_acc.add(_tf_usage) + + # --- Neue (thematisch gefilterte) Artikel speichern und für Analyse tracken --- + new_count = 0 + new_articles_for_analysis = [] + for article in new_candidates: cursor = await db.execute( """INSERT INTO articles (incident_id, headline, headline_de, source, source_url, content_original, content_de, language, published_at, tenant_id) @@ -971,7 +985,6 @@ class AgentOrchestrator: ), ) new_count += 1 - # Artikel mit DB-ID für die Analyse tracken article_with_id = dict(article) article_with_id["id"] = cursor.lastrowid new_articles_for_analysis.append(article_with_id)