Vorbereitung fuer jp_demo-Organisation: drei separate Sprach-Settings statt einer einzigen output_language. org_settings.py: - get_source_language_whitelist: Liste erlaubter Quellsprachen als JSON-Array (z.B. ["ja"] beschraenkt RSS/Telegram auf japanische Quellen). - get_research_language: Sprache fuer WebSearch-Prompts (Default: output_language). - get_translator_enabled: Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags. - LANGUAGE_DISPLAY_NAMES um ja/zh/ko/ru/ar/fa/he/fr/es erweitert. source_rules.py: - get_feeds_with_metadata filtert nach source_language_whitelist, wenn gesetzt. - Feeds ohne primary_language fallen bei aktiver Whitelist raus (gewollt). - SELECT um media_type erweitert, damit es im Feed-Dict ankommt. orchestrator.py: - Laedt research_language, source_language_whitelist, translator_enabled aus den Org-Settings. - Wenn Whitelist gesetzt: international_sources-Flag wird ignoriert. - research_language_iso wird an researcher.search() weitergegeben. - translate_articles bekommt enabled-Parameter aus Org-Setting. - Geoparsing ueberspringt media_type='forum' Artikel. - SELECT * FROM articles wird zu JOIN sources, damit media_type beim Reload am Article-Dict haengt. researcher.py: - search() akzeptiert research_language_iso. Asymmetrische Sprach-Auswahl (Recherche != Output) erzeugt eigene Prompt-Anweisung "primaer in Quell- sprache, englische Region-Outlets erlaubt". translator.py: - translate_articles akzeptiert enabled-Parameter. Ueberschreibt die globale TRANSLATOR_ENABLED-Konstante pro Aufruf. factchecker.py: - _format_articles_text filtert Artikel mit media_type='forum' aus. Anonyme Foren-Posts gelten nicht als Faktenbeleg. rss_parser.py: - _fetch_feed traegt media_type aus feed_config ins Article-Dict ein, damit downstream Pipeline-Schritte Foren-Quellen erkennen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
837 Zeilen
33 KiB
Python
837 Zeilen
33 KiB
Python
"""Factchecker-Agent: Prüft Fakten gegen mehrere unabhängige Quellen."""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from difflib import SequenceMatcher
|
|
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
|
|
|
|
logger = logging.getLogger("osint.factchecker")
|
|
|
|
FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
|
|
AUSGABESPRACHE: {output_language}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
VORFALL: {title}
|
|
|
|
VORLIEGENDE MELDUNGEN:
|
|
{articles_text}
|
|
|
|
STRENGE REGELN - KEINE HALLUZINATIONEN:
|
|
- Du darfst NUR Fakten bewerten, die direkt aus den oben übergebenen Meldungen stammen
|
|
- KEINE Fakten aus deinem Trainingskorpus - NUR aus den übergebenen Meldungen + WebSearch
|
|
- Nutze WebSearch um jeden Claim gegen mindestens 1 weitere unabhängige Quelle zu prüfen
|
|
- Rufe die gefundenen URLs per WebFetch ab um den Inhalt zu verifizieren
|
|
- Nur wenn du den Claim in der tatsächlich abgerufenen Quelle findest, darfst du ihn als bestätigt markieren
|
|
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten
|
|
- "confirmed" erst bei 2+ unabhängigen Quellen mit überprüfbarer URL
|
|
- Lieber "unconfirmed" als falsch bestätigt
|
|
|
|
AUFTRAG:
|
|
1. Identifiziere die 5-10 wichtigsten Faktenaussagen aus den Meldungen
|
|
2. Prüfe jeden Claim aktiv per WebSearch gegen mindestens eine weitere unabhängige Quelle
|
|
3. Kategorisiere jede Aussage:
|
|
- "confirmed": Durch 2+ unabhängige seriöse Quellen mit überprüfbarer URL bestätigt
|
|
- "unconfirmed": Nur 1 Quelle oder nicht unabhängig verifizierbar
|
|
- "contradicted": Widersprüchliche Informationen aus verschiedenen Quellen
|
|
- "developing": Situation noch unklar, entwickelt sich
|
|
4. Markiere WICHTIGE NEUE Entwicklungen mit is_notification: true
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON-Array. Jedes Element hat:
|
|
- "claim": Die Faktenaussage auf {output_language}
|
|
- "status": "confirmed" | "unconfirmed" | "contradicted" | "developing"
|
|
- "sources_count": Anzahl unabhängiger Quellen mit überprüfbarer URL
|
|
- "evidence": Begründung MIT konkreten Quellen-URLs als Beleg (z.B. "Bestätigt durch: tagesschau.de (URL), Reuters (URL)")
|
|
- "is_notification": true/false (nur bei wichtigen Entwicklungen true)
|
|
|
|
Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""
|
|
|
|
RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
|
|
AUSGABESPRACHE: {output_language}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
THEMA: {title}
|
|
|
|
VORLIEGENDE QUELLEN:
|
|
{articles_text}
|
|
|
|
STRENGE REGELN - KEINE HALLUZINATIONEN:
|
|
- Du darfst NUR Fakten bewerten, die direkt aus den oben übergebenen Quellen stammen
|
|
- KEINE Fakten aus deinem Trainingskorpus - NUR aus den übergebenen Quellen + WebSearch
|
|
- Nutze WebSearch um jeden Claim gegen mindestens 1 weitere unabhängige Quelle zu prüfen
|
|
- Rufe die gefundenen URLs per WebFetch ab um den Inhalt zu verifizieren
|
|
- Nur wenn du den Claim in der tatsächlich abgerufenen Quelle findest, darfst du ihn als gesichert markieren
|
|
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten
|
|
- Lieber "unverified" als falsch bestätigt
|
|
|
|
AUFTRAG:
|
|
Fokus: "Was sind die gesicherten Fakten zu diesem Thema?"
|
|
|
|
1. Identifiziere die 5-10 wichtigsten Faktenaussagen aus den Quellen
|
|
2. Prüfe jeden Claim aktiv per WebSearch gegen weitere unabhängige Quellen
|
|
3. Kategorisiere jede Aussage:
|
|
- "established": Breit dokumentierter, gesicherter Fakt (3+ unabhängige Quellen mit URL)
|
|
- "disputed": Umstrittener Sachverhalt, verschiedene Positionen dokumentiert
|
|
- "unverified": Einzelbehauptung, nicht unabhängig verifizierbar
|
|
- "developing": Aktuelle Entwicklung, Faktenlage noch im Fluss
|
|
4. Markiere WICHTIGE Erkenntnisse mit is_notification: true
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON-Array. Jedes Element hat:
|
|
- "claim": Die Faktenaussage auf {output_language}
|
|
- "status": "established" | "disputed" | "unverified" | "developing"
|
|
- "sources_count": Anzahl unabhängiger Quellen mit überprüfbarer URL
|
|
- "evidence": Begründung MIT konkreten Quellen-URLs als Beleg
|
|
- "is_notification": true/false
|
|
|
|
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}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
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}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
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."""
|
|
|
|
# --- Zwei-Phasen-Faktencheck: Prompt-Templates ---
|
|
|
|
TRIAGE_PROMPT_TEMPLATE = """Du bist ein Triage-System für Faktenchecks eines OSINT-Lagemonitoring-Systems.
|
|
AUSGABESPRACHE: {output_language}
|
|
|
|
BESTEHENDE FAKTENAUSSAGEN ({fact_count} Stück):
|
|
{existing_facts_text}
|
|
|
|
NEUE NACHRICHTENARTIKEL ({article_count} Stück):
|
|
{articles_text}
|
|
|
|
AUFGABE:
|
|
Analysiere die neuen Artikel und identifiziere:
|
|
|
|
1. BETROFFENE BESTEHENDE FAKTEN: Welche der bestehenden Fakten könnten durch die neuen Artikel
|
|
betroffen sein? (neue Bestätigung, Widerlegung, neue Evidenz, Status-Update nötig)
|
|
|
|
2. NEUE FAKTENAUSSAGEN: Welche neuen prüfbaren Faktenaussagen enthalten die Artikel,
|
|
die noch nicht in den bestehenden Fakten erfasst sind? (3-5 neue Claims)
|
|
|
|
3. GRUPPIERUNG: Gruppiere verwandte Fakten thematisch für die parallele Batch-Verarbeitung.
|
|
Verwandte Fakten MÜSSEN in derselben Gruppe sein. Max {max_per_group} Fakten pro Gruppe.
|
|
|
|
WICHTIG:
|
|
- Sei GRÜNDLICH — übersehe keine semantischen Verbindungen
|
|
- Auch indirekte Verbindungen beachten (diplomatisch <-> militärisch <-> wirtschaftlich)
|
|
- Alle developing/unconfirmed/unverified Fakten IMMER einbeziehen
|
|
- Max {max_per_group} Fakten pro Gruppe (teile große Gruppen auf)
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON:
|
|
{{{{
|
|
"affected_fact_ids": [int, ...],
|
|
"new_claims": ["Prüfbare Faktenaussage als vollständiger Satz", ...],
|
|
"groups": [
|
|
{{{{
|
|
"id": 1,
|
|
"theme": "Themenbezeichnung",
|
|
"fact_ids": [int, ...],
|
|
"new_claim_indices": [int, ...]
|
|
}}}}
|
|
],
|
|
"total_affected": int,
|
|
"reasoning": "Kurze Begründung"
|
|
}}}}"""
|
|
|
|
VERIFY_GROUP_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
|
|
AUSGABESPRACHE: {output_language}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
THEMA DIESER GRUPPE: {theme}
|
|
|
|
ZU PRÜFENDE BESTEHENDE FAKTEN:
|
|
{facts_text}
|
|
|
|
NEUE CLAIMS ZU PRÜFEN:
|
|
{new_claims_text}
|
|
|
|
KONTEXT (neue Nachrichtenartikel):
|
|
{articles_text}
|
|
|
|
ANWEISUNGEN:
|
|
Für JEDE Faktenaussage (bestehend UND neu):
|
|
1. Suche per WebSearch nach unabhängigen Bestätigungen oder Widerlegungen
|
|
2. Bewerte den Status:
|
|
- "confirmed": Mindestens 2 unabhängige Quellen mit verifizierter URL bestätigen
|
|
- "unconfirmed": Nicht genug unabhängige Bestätigung
|
|
- "contradicted": Glaubwürdige Quellen widersprechen
|
|
- "developing": Lage noch im Fluss
|
|
3. Dokumentiere die Evidenz mit konkreten, verifizierten URLs
|
|
|
|
QUALITÄTSREGELN:
|
|
- "confirmed" NUR bei mindestens 2 unabhängigen Quellen mit ECHTER URL
|
|
- KEINE Halluzinationen — nur tatsächlich per WebSearch gefundene Quellen
|
|
- Bestehende Evidenz BEIBEHALTEN, nur neue ergänzen
|
|
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
|
[
|
|
{{{{
|
|
"id": 123,
|
|
"claim": "Die Faktenaussage...",
|
|
"status": "confirmed|unconfirmed|contradicted|developing",
|
|
"sources_count": 3,
|
|
"evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
|
|
"is_notification": false
|
|
}}}}
|
|
]
|
|
|
|
Für NEUE Fakten setze id auf null."""
|
|
|
|
VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
|
|
AUSGABESPRACHE: {output_language}
|
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
|
|
|
THEMA DIESER GRUPPE: {theme}
|
|
|
|
ZU PRÜFENDE BESTEHENDE FAKTEN:
|
|
{facts_text}
|
|
|
|
NEUE CLAIMS ZU PRÜFEN:
|
|
{new_claims_text}
|
|
|
|
KONTEXT (neue Quellen):
|
|
{articles_text}
|
|
|
|
ANWEISUNGEN:
|
|
Für JEDE Faktenaussage (bestehend UND neu):
|
|
1. Suche per WebSearch nach unabhängigen Bestätigungen oder Widerlegungen
|
|
2. Bewerte den Status:
|
|
- "established": 3+ unabhängige Quellen mit verifizierter URL
|
|
- "unverified": Nicht unabhängig verifizierbar
|
|
- "disputed": Verschiedene Positionen dokumentiert
|
|
- "developing": Faktenlage im Fluss
|
|
3. Dokumentiere die Evidenz mit konkreten, verifizierten URLs
|
|
|
|
QUALITÄTSREGELN:
|
|
- "established" NUR bei mindestens 3 unabhängigen Quellen mit ECHTER URL
|
|
- KEINE Halluzinationen — nur tatsächlich per WebSearch gefundene Quellen
|
|
- Bestehende Evidenz BEIBEHALTEN, nur neue ergänzen
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
|
[
|
|
{{{{
|
|
"id": 123,
|
|
"claim": "Die Faktenaussage...",
|
|
"status": "established|unverified|disputed|developing",
|
|
"sources_count": 3,
|
|
"evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
|
|
"is_notification": false
|
|
}}}}
|
|
]
|
|
|
|
Für NEUE Fakten setze id auf null."""
|
|
|
|
|
|
# --- Stopwords fuer Keyword-Extraktion ---
|
|
_STOPWORDS = frozenset({
|
|
"der", "die", "das", "ein", "eine", "und", "oder", "von", "nach", "bei", "mit",
|
|
"wurde", "wird", "haben", "sein", "dass", "ist", "sind", "hat", "vor", "fuer",
|
|
"den", "dem", "des", "sich", "auf", "als", "auch", "noch", "nicht", "aber",
|
|
"ueber", "durch", "einer", "einem", "eines", "werden", "wurde", "waren",
|
|
"the", "and", "was", "has", "been", "have", "that", "with", "from", "for",
|
|
"are", "were", "this", "which", "into", "their", "than", "about",
|
|
})
|
|
|
|
STATUS_PRIORITY = {
|
|
"confirmed": 5, "established": 5,
|
|
"contradicted": 4, "disputed": 4,
|
|
"unconfirmed": 3, "unverified": 3,
|
|
"developing": 1,
|
|
}
|
|
|
|
# Zwei-Phasen-Faktencheck: Konfiguration
|
|
MAX_FACTS_PER_VERIFY_GROUP = 8 # Max Fakten pro Verifikationsgruppe
|
|
TWOPHASE_MIN_FACTS = 25 # Ab dieser Anzahl bestehender Fakten wird der Zwei-Phasen-Ansatz genutzt
|
|
|
|
|
|
def normalize_claim(claim: str) -> str:
|
|
"""Normalisiert einen Claim fuer Aehnlichkeitsvergleich."""
|
|
c = claim.lower().strip()
|
|
c = c.replace("\u00e4", "ae").replace("\u00f6", "oe").replace("\u00fc", "ue").replace("\u00df", "ss")
|
|
c = re.sub(r'[^\w\s]', '', c)
|
|
c = re.sub(r'\s+', ' ', c).strip()
|
|
return c
|
|
|
|
|
|
def _keyword_set(text: str) -> set[str]:
|
|
"""Extrahiert signifikante Woerter fuer Overlap-Vergleich."""
|
|
words = set(normalize_claim(text).split())
|
|
return {w for w in words if len(w) >= 4 and w not in _STOPWORDS}
|
|
|
|
|
|
def find_matching_claim(new_claim: str, existing_claims: list[dict], threshold: float = 0.75) -> dict | None:
|
|
"""Findet den besten passenden bestehenden Claim per kombiniertem Scoring.
|
|
|
|
Verwendet SequenceMatcher (70%) + Jaccard-Keyword-Overlap (30%) fuer robusteres Matching.
|
|
"""
|
|
norm_new = normalize_claim(new_claim)
|
|
if not norm_new:
|
|
return None
|
|
|
|
kw_new = _keyword_set(new_claim)
|
|
best_match = None
|
|
best_score = 0.0
|
|
|
|
for existing in existing_claims:
|
|
norm_existing = normalize_claim(existing.get("claim", ""))
|
|
if not norm_existing:
|
|
continue
|
|
|
|
# Fruehzeitiger Abbruch bei grossem Laengenunterschied
|
|
len_ratio = len(norm_new) / len(norm_existing) if norm_existing else 0
|
|
if len_ratio > 2.5 or len_ratio < 0.4:
|
|
continue
|
|
|
|
seq_ratio = SequenceMatcher(None, norm_new, norm_existing).ratio()
|
|
|
|
kw_existing = _keyword_set(existing.get("claim", ""))
|
|
kw_union = kw_new | kw_existing
|
|
jaccard = len(kw_new & kw_existing) / len(kw_union) if kw_union else 0.0
|
|
|
|
combined = 0.7 * seq_ratio + 0.3 * jaccard
|
|
|
|
if combined > best_score:
|
|
best_score = combined
|
|
best_match = existing
|
|
|
|
if best_score >= threshold:
|
|
logger.debug(
|
|
f"Claim-Match ({best_score:.2f}): "
|
|
f"'{new_claim[:50]}...' -> '{best_match['claim'][:50]}...'"
|
|
)
|
|
return best_match
|
|
return None
|
|
|
|
|
|
def deduplicate_new_facts(facts: list[dict], threshold: float = 0.70) -> list[dict]:
|
|
"""Dedupliziert Fakten aus einer einzelnen LLM-Antwort vor dem DB-Insert.
|
|
|
|
Clustert aehnliche Claims und behaelt pro Cluster den mit dem
|
|
hoechsten Status und den meisten Quellen.
|
|
"""
|
|
if not facts:
|
|
return []
|
|
|
|
clusters: list[list[dict]] = []
|
|
for fact in facts:
|
|
matched_cluster = None
|
|
for cluster in clusters:
|
|
if find_matching_claim(fact.get("claim", ""), cluster, threshold=threshold):
|
|
matched_cluster = cluster
|
|
break
|
|
if matched_cluster is not None:
|
|
matched_cluster.append(fact)
|
|
else:
|
|
clusters.append([fact])
|
|
|
|
result = []
|
|
for cluster in clusters:
|
|
best = max(cluster, key=lambda f: (
|
|
STATUS_PRIORITY.get(f.get("status", "developing"), 0),
|
|
f.get("sources_count", 0),
|
|
))
|
|
result.append(best)
|
|
|
|
if len(result) < len(facts):
|
|
logger.info(
|
|
f"Fakten-Dedup: {len(facts)} -> {len(result)} "
|
|
f"(-{len(facts) - len(result)} Duplikate)"
|
|
)
|
|
return result
|
|
|
|
|
|
class FactCheckerAgent:
|
|
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
|
|
|
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
|
"""Formatiert Artikel als Text für den Prompt.
|
|
|
|
Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier
|
|
ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein
|
|
anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X"
|
|
gelten.
|
|
"""
|
|
# Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern.
|
|
# Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt;
|
|
# bei Reload aus der DB muss der Orchestrator das per JOIN annotieren.
|
|
non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"]
|
|
skipped = len(articles) - len(non_forum)
|
|
if skipped > 0:
|
|
logger.info(
|
|
"Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, "
|
|
"%d Artikel als Faktenbeleg-Kandidaten",
|
|
skipped, len(non_forum),
|
|
)
|
|
|
|
articles_text = ""
|
|
for i, article in enumerate(non_forum[: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', '')
|
|
if source_url:
|
|
articles_text += f"URL: {source_url}\n"
|
|
headline = article.get('headline_de') or article.get('headline', '')
|
|
articles_text += f"Überschrift: {headline}\n"
|
|
content = article.get('content_de') or article.get('content_original', '')
|
|
if content:
|
|
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)
|
|
evidence = (fc.get("evidence") or "")[:200]
|
|
line = f"- [{status}] ({sources} Quellen) {claim}"
|
|
if evidence:
|
|
line += f"\n Evidenz: {evidence}"
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc", output_language: str = "Deutsch") -> 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)
|
|
|
|
template = RESEARCH_FACTCHECK_PROMPT_TEMPLATE if incident_type == "research" else FACTCHECK_PROMPT_TEMPLATE
|
|
prompt = template.format(
|
|
title=title,
|
|
articles_text=articles_text,
|
|
output_language=output_language,
|
|
)
|
|
|
|
try:
|
|
result, usage = await call_claude(prompt)
|
|
facts = self._parse_response(result, articles=articles)
|
|
logger.info(f"Faktencheck: {len(facts)} Fakten geprüft")
|
|
return facts, usage
|
|
except TimeoutError:
|
|
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
|
except Exception as e:
|
|
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",
|
|
output_language: str = "Deutsch",
|
|
) -> 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)
|
|
|
|
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, articles=new_articles)
|
|
logger.info(f"Inkrementeller Faktencheck: {len(facts)} Fakten (neu + aktualisiert)")
|
|
return facts, usage
|
|
except TimeoutError:
|
|
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
|
except Exception as e:
|
|
logger.error(f"Inkrementeller Faktencheck-Fehler: {e}")
|
|
return [], None
|
|
|
|
async def check_incremental_twophase(
|
|
self,
|
|
title: str,
|
|
new_articles: list[dict],
|
|
existing_facts: list[dict],
|
|
incident_type: str = "adhoc",
|
|
output_language: str = "Deutsch",
|
|
) -> tuple[list[dict], ClaudeUsage | None]:
|
|
"""Zwei-Phasen inkrementeller Faktencheck: Haiku-Triage + parallele Opus-Verifikation.
|
|
|
|
Phase 1: Haiku identifiziert betroffene Fakten und neue Claims (schnell, günstig)
|
|
Phase 2: Opus verifiziert nur die betroffenen Fakten parallel per WebSearch
|
|
|
|
Fällt bei Triage-Fehler auf den Standard-check_incremental zurück.
|
|
"""
|
|
if not new_articles:
|
|
logger.info("Zwei-Phasen-Faktencheck übersprungen: keine neuen Artikel")
|
|
return [], None
|
|
|
|
usage_acc = UsageAccumulator()
|
|
|
|
# --- Phase 1: Triage (Haiku) ---
|
|
logger.info(f"Zwei-Phasen-Faktencheck Phase 1: Triage ({len(existing_facts)} Fakten, {len(new_articles)} Artikel)")
|
|
|
|
triage_facts_text = self._format_facts_for_triage(existing_facts)
|
|
articles_text = self._format_articles_text(new_articles, max_articles=15)
|
|
|
|
from config import CLAUDE_MODEL_FAST
|
|
triage_prompt = TRIAGE_PROMPT_TEMPLATE.format(
|
|
output_language=output_language,
|
|
fact_count=len(existing_facts),
|
|
existing_facts_text=triage_facts_text,
|
|
article_count=len(new_articles),
|
|
articles_text=articles_text,
|
|
max_per_group=MAX_FACTS_PER_VERIFY_GROUP,
|
|
)
|
|
|
|
try:
|
|
triage_result, triage_usage = await call_claude(
|
|
triage_prompt, tools=None, model=CLAUDE_MODEL_FAST
|
|
)
|
|
if triage_usage:
|
|
usage_acc.add(triage_usage)
|
|
except Exception as e:
|
|
logger.warning(f"Triage fehlgeschlagen, Fallback auf Standard-Faktencheck: {e}")
|
|
return await self.check_incremental(title, new_articles, existing_facts, incident_type)
|
|
|
|
# Triage-Ergebnis parsen
|
|
triage = self._parse_triage_response(triage_result)
|
|
if not triage:
|
|
logger.warning("Triage-Antwort nicht parsbar, Fallback auf Standard-Faktencheck")
|
|
return await self.check_incremental(title, new_articles, existing_facts, incident_type)
|
|
|
|
affected_ids = set(triage.get("affected_fact_ids", []))
|
|
new_claims = triage.get("new_claims", [])
|
|
groups = triage.get("groups", [])
|
|
|
|
if not groups:
|
|
logger.warning("Triage hat keine Gruppen identifiziert, Fallback auf Standard-Faktencheck")
|
|
return await self.check_incremental(title, new_articles, existing_facts, incident_type)
|
|
|
|
logger.info(
|
|
f"Triage: {len(affected_ids)} betroffene Fakten, {len(new_claims)} neue Claims, {len(groups)} Gruppen"
|
|
)
|
|
|
|
# --- Phase 2: Parallele Verifikation (Opus) ---
|
|
fact_lookup = {f["id"]: f for f in existing_facts}
|
|
|
|
async def _verify_one_group(group: dict) -> tuple[list[dict], ClaudeUsage | None]:
|
|
"""Verifiziert eine einzelne Faktengruppe."""
|
|
group_fact_ids = group.get("fact_ids", [])
|
|
group_new_indices = group.get("new_claim_indices", [])
|
|
theme = group.get("theme", "Allgemein")
|
|
|
|
group_facts = [fact_lookup[fid] for fid in group_fact_ids if fid in fact_lookup]
|
|
group_claims = [new_claims[i] for i in group_new_indices if i < len(new_claims)]
|
|
|
|
if not group_facts and not group_claims:
|
|
return [], None
|
|
|
|
# Fakten formatieren (mit Evidenz für Kontext)
|
|
facts_text = self._format_facts_for_verify(group_facts) if group_facts else "Keine bestehenden Fakten in dieser Gruppe."
|
|
new_claims_text = "\n".join(f"- {c}" for c in group_claims) if group_claims else "Keine neuen Claims."
|
|
|
|
if incident_type == "research":
|
|
template = VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE
|
|
else:
|
|
template = VERIFY_GROUP_PROMPT_TEMPLATE
|
|
|
|
prompt = template.format(
|
|
output_language=output_language,
|
|
theme=theme,
|
|
facts_text=facts_text,
|
|
new_claims_text=new_claims_text,
|
|
articles_text=articles_text,
|
|
)
|
|
|
|
try:
|
|
result, v_usage = await call_claude(prompt)
|
|
facts = self._parse_response(result)
|
|
logger.info(f"Gruppe '{theme}': {len(facts)} Fakten geprüft")
|
|
return facts, v_usage
|
|
except TimeoutError:
|
|
logger.error(f"Gruppe '{theme}': Timeout")
|
|
return [], None
|
|
except Exception as e:
|
|
logger.error(f"Gruppe '{theme}': Fehler: {e}")
|
|
return [], None
|
|
|
|
logger.info(f"Zwei-Phasen-Faktencheck Phase 2: {len(groups)} Gruppen parallel verifizieren")
|
|
|
|
# Alle Gruppen parallel starten
|
|
group_results = await asyncio.gather(
|
|
*(_verify_one_group(g) for g in groups)
|
|
)
|
|
|
|
# Ergebnisse zusammenführen
|
|
all_facts = []
|
|
for facts, v_usage in group_results:
|
|
all_facts.extend(facts)
|
|
if v_usage:
|
|
usage_acc.add(v_usage)
|
|
|
|
# Kombinierte Usage erstellen
|
|
combined_usage = ClaudeUsage(
|
|
input_tokens=usage_acc.input_tokens,
|
|
output_tokens=usage_acc.output_tokens,
|
|
cache_creation_tokens=usage_acc.cache_creation_tokens,
|
|
cache_read_tokens=usage_acc.cache_read_tokens,
|
|
cost_usd=usage_acc.total_cost_usd,
|
|
duration_ms=0,
|
|
)
|
|
|
|
logger.info(
|
|
f"Zwei-Phasen-Faktencheck abgeschlossen: {len(all_facts)} Fakten "
|
|
f"({usage_acc.call_count} API-Calls, ${usage_acc.total_cost_usd:.4f})"
|
|
)
|
|
|
|
return all_facts, combined_usage
|
|
|
|
def _format_facts_for_triage(self, facts: list[dict]) -> str:
|
|
"""Formatiert Fakten kompakt mit IDs für die Triage."""
|
|
lines = []
|
|
for f in facts:
|
|
status = f.get("status", "developing")
|
|
claim = f.get("claim", "")
|
|
sources = f.get("sources_count", 0)
|
|
fid = f.get("id", "?")
|
|
lines.append(f"- [ID:{fid}] [{status}] ({sources} Quellen) {claim}")
|
|
return "\n".join(lines)
|
|
|
|
def _format_facts_for_verify(self, facts: list[dict]) -> str:
|
|
"""Formatiert Fakten detailliert für die Verifikation."""
|
|
parts = []
|
|
for f in facts:
|
|
evidence = (f.get("evidence") or "")[:300]
|
|
parts.append(
|
|
f"ID: {f.get('id', '?')}\n"
|
|
f"Status: {f.get('status', 'developing')}\n"
|
|
f"Claim: {f.get('claim', '')}\n"
|
|
f"Bisherige Evidenz: {evidence}\n"
|
|
f"Quellen: {f.get('sources_count', 0)}"
|
|
)
|
|
return "\n---\n".join(parts)
|
|
|
|
def _parse_triage_response(self, response: str) -> dict | None:
|
|
"""Parst die Triage-Antwort als JSON-Objekt."""
|
|
try:
|
|
data = json.loads(response)
|
|
if isinstance(data, dict) and "groups" in data:
|
|
return data
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
match = re.search(r'\{.*\}', response, re.DOTALL)
|
|
if match:
|
|
try:
|
|
data = json.loads(match.group())
|
|
if isinstance(data, dict) and "groups" in data:
|
|
return data
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
logger.warning("Konnte Triage-Antwort nicht als JSON parsen")
|
|
return None
|
|
|
|
def _validate_facts(self, facts: list[dict], articles: list[dict] = None) -> list[dict]:
|
|
"""Validiert Fakten und ordnet Quellen-URLs aus den Artikeln zu.
|
|
|
|
Stellt sicher, dass jeder confirmed/established Fakt URLs in der
|
|
evidence hat, damit das Frontend die Quellen korrekt anzeigen kann.
|
|
"""
|
|
url_pattern = re.compile(r'https?://')
|
|
# Verfuegbare Artikel-URLs sammeln (dedupliziert nach URL)
|
|
article_sources = []
|
|
seen_urls = set()
|
|
if articles:
|
|
for a in articles:
|
|
url = a.get("source_url", "")
|
|
source = a.get("source", "")
|
|
headline = a.get("headline_de") or a.get("headline", "")
|
|
if url and url not in seen_urls:
|
|
seen_urls.add(url)
|
|
article_sources.append({"url": url, "source": source, "headline": headline})
|
|
|
|
for fact in facts:
|
|
status = fact.get("status", "")
|
|
evidence = fact.get("evidence") or ""
|
|
|
|
# Fuer alle Fakten: Quellen zuordnen
|
|
if status not in ("retracted",):
|
|
# Bereits vorhandene URLs in der evidence zaehlen
|
|
existing_urls = set(url_pattern.findall(evidence))
|
|
|
|
# Passende Quellen per Keyword-Match finden
|
|
claim_lower = (fact.get("claim") or "").lower()
|
|
evidence_lower = evidence.lower()
|
|
claim_words = [w for w in claim_lower.split() if len(w) >= 4][:10]
|
|
|
|
scored_sources = []
|
|
for src in article_sources:
|
|
if src["url"] in existing_urls:
|
|
continue # Bereits in evidence
|
|
src_text = (src["headline"] + " " + src["source"]).lower()
|
|
matches = sum(1 for w in claim_words if w in src_text)
|
|
if matches >= max(1, len(claim_words) // 5):
|
|
scored_sources.append((matches, src))
|
|
|
|
# Nach Relevanz sortieren, Top 5 nehmen
|
|
scored_sources.sort(key=lambda x: x[0], reverse=True)
|
|
matched_sources = [s for _, s in scored_sources[:5]]
|
|
|
|
if matched_sources:
|
|
source_refs = "; ".join(
|
|
f"{s['source']} ({s['url']})" for s in matched_sources
|
|
)
|
|
if existing_urls:
|
|
# Bereits URLs vorhanden, weitere ergaenzen
|
|
fact["evidence"] = (
|
|
evidence.rstrip(". ") +
|
|
". [Weitere Quellen: " + source_refs + "]"
|
|
)
|
|
else:
|
|
# Keine URLs vorhanden, Quellen anhaengen
|
|
fact["evidence"] = (
|
|
evidence.rstrip(". ") +
|
|
". [Quellen: " + source_refs + "]"
|
|
)
|
|
|
|
# sources_count aktualisieren
|
|
all_urls = url_pattern.findall(fact["evidence"])
|
|
fact["sources_count"] = len(set(all_urls))
|
|
|
|
logger.info(
|
|
f"Fakt '{fact.get('claim', '')[:50]}...' ergaenzt mit "
|
|
f"{len(matched_sources)} Quelle(n), gesamt: {fact['sources_count']}"
|
|
)
|
|
elif not existing_urls:
|
|
# Weder bestehende URLs noch passende Quellen
|
|
old_status = status
|
|
fact["status"] = "unconfirmed" if status == "confirmed" else "unverified"
|
|
logger.warning(
|
|
f"Fakt herabgestuft ({old_status} -> {fact['status']}): "
|
|
f"keine Quellen zuordnebar: '{fact.get('claim', '')[:60]}...'"
|
|
)
|
|
return facts
|
|
|
|
def _parse_response(self, response: str, articles: list[dict] = None) -> list[dict]:
|
|
"""Parst die Claude-Antwort als JSON-Array."""
|
|
try:
|
|
data = json.loads(response)
|
|
if isinstance(data, list):
|
|
return self._validate_facts(data, articles=articles)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
match = re.search(r'\[.*\]', response, re.DOTALL)
|
|
if match:
|
|
try:
|
|
data = json.loads(match.group())
|
|
if isinstance(data, list):
|
|
return self._validate_facts(data)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
logger.warning("Konnte Faktencheck-Antwort nicht als JSON parsen")
|
|
return []
|