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