Inkrementelle Analyse + Token-Optimierung + Relevanz-Scoring
TOKEN-OPTIMIERUNG: - Inkrementelle Analyse: Folge-Refreshes senden nur noch das bisherige Lagebild + neue Artikel an Claude (statt alle Artikel erneut). Spart ~60-70% Tokens bei Lagen mit vielen Artikeln. - Inkrementeller Faktencheck: Bestehende Fakten als Zusammenfassung, nur neue Artikel werden vollstaendig geprueft. - Modell-Steuerung: Feed-Selektion nutzt jetzt Haiku (CLAUDE_MODEL_FAST) statt Opus. Spart ~50-70% bei Feed-Auswahl. - Set-basierte DB-Deduplizierung: Bestehende URLs/Headlines einmal in Sets geladen statt N*M einzelne DB-Queries pro Artikel. INHALTLICHE VERBESSERUNGEN: - Relevanz-Scoring: Artikel nach Keyword-Dichte (40%), Quellen-Reputation (30%), Inhaltstiefe (20%), RSS-Score (10%). - Flexibles RSS-Matching: min. Haelfte der Keywords statt alle. RSS-Artikel bekommen einen relevance_score. - Fuzzy Claim-Matching: SequenceMatcher (0.7) statt exakter String-Vergleich. Verhindert Duplikat-Akkumulation. - Translation-Fix: Nur gueltige DB-IDs (isinstance int). - Researcher: WebFetch fuer Top-Artikel, erweiterte Zusammenfassungen. DATEIEN: - config.py: CLAUDE_MODEL_FAST - claude_client.py: model-Parameter - researcher.py: Haiku Feed-Selektion, erweiterte Prompts - analyzer.py: Inkrementelle Analyse + analyze_incremental() - factchecker.py: Inkrementeller Check + Fuzzy-Matching - orchestrator.py: Set-Dedup, Relevanz-Scoring, inkrementeller Flow - rss_parser.py: Flexibles Keyword-Matching + relevance_score
Dieser Commit ist enthalten in:
@@ -2,6 +2,7 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from agents.claude_client import call_claude, ClaudeUsage
|
||||
|
||||
logger = logging.getLogger("osint.factchecker")
|
||||
@@ -81,17 +82,138 @@ Antworte AUSSCHLIESSLICH als JSON-Array. Jedes Element hat:
|
||||
|
||||
Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""
|
||||
|
||||
# --- Inkrementelle Faktencheck-Prompts (für Folge-Refreshes) ---
|
||||
|
||||
INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
|
||||
AUSGABESPRACHE: {output_language}
|
||||
|
||||
VORFALL: {title}
|
||||
|
||||
BEREITS GEPRÜFTE FAKTEN:
|
||||
{existing_facts_text}
|
||||
|
||||
NEUE MELDUNGEN:
|
||||
{articles_text}
|
||||
|
||||
STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
- Du darfst NUR Fakten bewerten, die aus den Meldungen oder bereits geprüften Fakten stammen
|
||||
- KEINE Fakten aus deinem Trainingskorpus
|
||||
- Nutze WebSearch zur Verifikation
|
||||
- Rufe gefundene URLs per WebFetch ab
|
||||
|
||||
AUFTRAG:
|
||||
1. Prüfe ob die neuen Meldungen bereits geprüfte Fakten BESTÄTIGEN, WIDERLEGEN oder ERGÄNZEN
|
||||
2. Aktualisiere den Status bestehender Fakten wenn nötig (z.B. "unconfirmed" → "confirmed")
|
||||
3. Identifiziere 3-5 NEUE Faktenaussagen aus den neuen Meldungen
|
||||
4. Prüfe neue Claims per WebSearch gegen unabhängige Quellen
|
||||
5. Markiere wichtige Statusänderungen und neue Entwicklungen mit is_notification: true
|
||||
|
||||
Status-Kategorien:
|
||||
- "confirmed": 2+ unabhängige seriöse Quellen mit URL
|
||||
- "unconfirmed": Nur 1 Quelle
|
||||
- "contradicted": Widersprüchliche Informationen
|
||||
- "developing": Situation unklar
|
||||
|
||||
Antworte AUSSCHLIESSLICH als JSON-Array mit ALLEN Fakten (bestehende aktualisiert + neue).
|
||||
Jedes Element hat:
|
||||
- "claim": Die Faktenaussage auf {output_language}
|
||||
- "status": "confirmed" | "unconfirmed" | "contradicted" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Quellen
|
||||
- "evidence": Begründung MIT konkreten Quellen-URLs
|
||||
- "is_notification": true/false
|
||||
|
||||
Antworte NUR mit dem JSON-Array."""
|
||||
|
||||
INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
|
||||
AUSGABESPRACHE: {output_language}
|
||||
|
||||
THEMA: {title}
|
||||
|
||||
BEREITS GEPRÜFTE FAKTEN:
|
||||
{existing_facts_text}
|
||||
|
||||
NEUE QUELLEN:
|
||||
{articles_text}
|
||||
|
||||
STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
- Du darfst NUR Fakten bewerten, die aus den Quellen oder bereits geprüften Fakten stammen
|
||||
- KEINE Fakten aus deinem Trainingskorpus
|
||||
- Nutze WebSearch zur Verifikation
|
||||
- Rufe gefundene URLs per WebFetch ab
|
||||
|
||||
AUFTRAG:
|
||||
1. Prüfe ob die neuen Quellen bereits geprüfte Fakten bestätigen, widerlegen oder ergänzen
|
||||
2. Aktualisiere den Status bestehender Fakten wenn nötig
|
||||
3. Identifiziere 3-5 NEUE Faktenaussagen aus den neuen Quellen
|
||||
4. Prüfe neue Claims per WebSearch
|
||||
|
||||
Status-Kategorien:
|
||||
- "established": 3+ unabhängige Quellen mit URL
|
||||
- "disputed": Verschiedene Positionen dokumentiert
|
||||
- "unverified": Nicht unabhängig verifizierbar
|
||||
- "developing": Faktenlage im Fluss
|
||||
|
||||
Antworte AUSSCHLIESSLICH als JSON-Array mit ALLEN Fakten (bestehende aktualisiert + neue).
|
||||
Jedes Element hat:
|
||||
- "claim": Die Faktenaussage auf {output_language}
|
||||
- "status": "established" | "disputed" | "unverified" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Quellen
|
||||
- "evidence": Begründung MIT konkreten Quellen-URLs
|
||||
- "is_notification": true/false
|
||||
|
||||
Antworte NUR mit dem JSON-Array."""
|
||||
|
||||
|
||||
def normalize_claim(claim: str) -> str:
|
||||
"""Normalisiert einen Claim für Ähnlichkeitsvergleich."""
|
||||
c = claim.lower().strip()
|
||||
# Umlaute normalisieren
|
||||
c = c.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
c = re.sub(r'[^\w\s]', '', c)
|
||||
c = re.sub(r'\s+', ' ', c).strip()
|
||||
return c
|
||||
|
||||
|
||||
def find_matching_claim(new_claim: str, existing_claims: list[dict], threshold: float = 0.7) -> dict | None:
|
||||
"""Findet den besten passenden bestehenden Claim per Fuzzy-Matching.
|
||||
|
||||
Args:
|
||||
new_claim: Der neue Claim-Text
|
||||
existing_claims: Liste von Dicts mit mindestens {"id", "claim", "status"}
|
||||
threshold: Mindest-Ähnlichkeit (0.0-1.0), Standard 0.7
|
||||
|
||||
Returns:
|
||||
Das passende Dict oder None wenn kein Match über dem Schwellwert
|
||||
"""
|
||||
norm_new = normalize_claim(new_claim)
|
||||
if not norm_new:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
best_ratio = 0.0
|
||||
|
||||
for existing in existing_claims:
|
||||
norm_existing = normalize_claim(existing.get("claim", ""))
|
||||
if not norm_existing:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, norm_new, norm_existing).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_match = existing
|
||||
|
||||
if best_ratio >= threshold:
|
||||
logger.debug(f"Claim-Match ({best_ratio:.2f}): '{new_claim[:50]}...' → '{best_match['claim'][:50]}...'")
|
||||
return best_match
|
||||
return None
|
||||
|
||||
|
||||
class FactCheckerAgent:
|
||||
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
||||
|
||||
async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[list[dict], ClaudeUsage | None]:
|
||||
"""Führt Faktencheck für eine Lage durch."""
|
||||
if not articles:
|
||||
return [], None
|
||||
|
||||
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
||||
"""Formatiert Artikel als Text für den Prompt."""
|
||||
articles_text = ""
|
||||
for i, article in enumerate(articles[:20]):
|
||||
for i, article in enumerate(articles[:max_articles]):
|
||||
articles_text += f"\n--- Meldung {i+1} ---\n"
|
||||
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
|
||||
source_url = article.get('source_url', '')
|
||||
@@ -101,7 +223,27 @@ class FactCheckerAgent:
|
||||
articles_text += f"Überschrift: {headline}\n"
|
||||
content = article.get('content_de') or article.get('content_original', '')
|
||||
if content:
|
||||
articles_text += f"Inhalt: {content[:300]}\n"
|
||||
articles_text += f"Inhalt: {content[:500]}\n"
|
||||
return articles_text
|
||||
|
||||
def _format_existing_facts(self, facts: list[dict]) -> str:
|
||||
"""Formatiert bestehende Fakten als Text für den inkrementellen Prompt."""
|
||||
if not facts:
|
||||
return "Keine bisherigen Fakten"
|
||||
lines = []
|
||||
for fc in facts:
|
||||
status = fc.get("status", "developing")
|
||||
claim = fc.get("claim", "")
|
||||
sources = fc.get("sources_count", 0)
|
||||
lines.append(f"- [{status}] ({sources} Quellen) {claim}")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[list[dict], ClaudeUsage | None]:
|
||||
"""Führt vollständigen Faktencheck durch (erster Refresh)."""
|
||||
if not articles:
|
||||
return [], None
|
||||
|
||||
articles_text = self._format_articles_text(articles)
|
||||
|
||||
from config import OUTPUT_LANGUAGE
|
||||
template = RESEARCH_FACTCHECK_PROMPT_TEMPLATE if incident_type == "research" else FACTCHECK_PROMPT_TEMPLATE
|
||||
@@ -120,6 +262,46 @@ class FactCheckerAgent:
|
||||
logger.error(f"Faktencheck-Fehler: {e}")
|
||||
return [], None
|
||||
|
||||
async def check_incremental(
|
||||
self,
|
||||
title: str,
|
||||
new_articles: list[dict],
|
||||
existing_facts: list[dict],
|
||||
incident_type: str = "adhoc",
|
||||
) -> tuple[list[dict], ClaudeUsage | None]:
|
||||
"""Inkrementeller Faktencheck: Prüft nur neue Artikel gegen bestehende Fakten.
|
||||
|
||||
Spart Tokens, da nur neue Artikel + Zusammenfassung der bestehenden Fakten gesendet werden.
|
||||
"""
|
||||
if not new_articles:
|
||||
logger.info("Inkrementeller Faktencheck übersprungen: keine neuen Artikel")
|
||||
return [], None
|
||||
|
||||
articles_text = self._format_articles_text(new_articles, max_articles=15)
|
||||
existing_facts_text = self._format_existing_facts(existing_facts)
|
||||
|
||||
from config import OUTPUT_LANGUAGE
|
||||
if incident_type == "research":
|
||||
template = INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE
|
||||
else:
|
||||
template = INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE
|
||||
|
||||
prompt = template.format(
|
||||
title=title,
|
||||
articles_text=articles_text,
|
||||
existing_facts_text=existing_facts_text,
|
||||
output_language=OUTPUT_LANGUAGE,
|
||||
)
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
facts = self._parse_response(result)
|
||||
logger.info(f"Inkrementeller Faktencheck: {len(facts)} Fakten (neu + aktualisiert)")
|
||||
return facts, usage
|
||||
except Exception as e:
|
||||
logger.error(f"Inkrementeller Faktencheck-Fehler: {e}")
|
||||
return [], None
|
||||
|
||||
def _parse_response(self, response: str) -> list[dict]:
|
||||
"""Parst die Claude-Antwort als JSON-Array."""
|
||||
try:
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren