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.
Dieser Commit ist enthalten in:
claude-dev
2026-04-21 12:01:56 +00:00
Ursprung 285df86c7b
Commit 60b8646fe4
2 geänderte Dateien mit 113 neuen und 6 gelöschten Zeilen

Datei anzeigen

@@ -242,9 +242,37 @@ OUTPUT-FORMAT (ausschliesslich, keine Anführungszeichen, kein Code-Fence, JEDE
- [DD.MM. HH:MM] Ereignistext neu. {{M<ID>}}
- [DD.MM. HH:MM] Ereignistext neu mit mehreren Belegen. {{M<ID1>, M<ID2>}}
- [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,