Commits vergleichen
33 Commits
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
896791608a | ||
|
|
bd35e25121 | ||
|
|
f8d32b1d18 | ||
|
|
ffbdd5170c | ||
|
|
4ba66cb510 | ||
|
|
c09c2a49b9 | ||
|
|
e440ef93d1 | ||
|
|
b00076d320 | ||
|
|
d948e3a7f4 | ||
|
|
5a75f0fb15 | ||
|
|
695983245d | ||
|
|
e2638ce9d5 | ||
|
|
9868a9748a | ||
|
|
de3cb7223f | ||
|
|
0154c0f575 | ||
|
|
3495409377 | ||
|
|
f87d377082 | ||
|
|
58aec2edd5 | ||
|
|
bf967216de | ||
|
|
5a08507217 | ||
|
|
0f39154e39 | ||
|
|
c4e19777f9 | ||
|
|
768150c302 | ||
|
|
b7a2a120cd | ||
|
|
cd50cd8430 | ||
|
|
fb15f3ac8d | ||
|
|
8f5c0e9e5d | ||
|
|
b32c54b22c | ||
|
|
676741b1c4 | ||
|
|
5fffca614a | ||
|
|
a7f1aa29ab | ||
|
|
e0edac2a18 | ||
|
|
fc33cccd44 |
74
CLAUDE.md
74
CLAUDE.md
@@ -26,6 +26,28 @@ backend:
|
||||
scheduler: APScheduler (PDF-Ingest 1min, Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00, Telegram-Status taeglich 04:30)
|
||||
websocket: FastAPI native (Echtzeit-Updates an Clients)
|
||||
ki: Claude CLI als Subprocess (WebSearch + WebFetch Tools)
|
||||
ki_backend_eu: |
|
||||
EU-Umbau Phase 1 (seit 2026-07-29): zweiter Modellweg ueber AWS Bedrock
|
||||
(EU-Inferenzprofile Frankfurt, agents/bedrock_client.py). Umschaltbar je
|
||||
Lage (incidents.ai_backend) > Organisation (org_setting 'ai_backend') >
|
||||
global (ENV AI_BACKEND, Default 'cli'). Aufloesung je Refresh im
|
||||
Orchestrator via ContextVar _ai_backend_var (gemeinsame Aufloesung mit der
|
||||
Lane-Wahl, siehe _resolve_ai_backend). Seit Phase 2 (2026-07-30) laeuft im
|
||||
EU-Modus auch die RECHERCHE europaeisch: agents/eu_researcher.py steuert
|
||||
eine Schleife aus Bedrock-Planung und staan-Suchen (services/staan_client.py)
|
||||
und liefert dasselbe JSON wie die CLI-WebSearch. Werkzeuglose Aufrufe gehen
|
||||
direkt ueber bedrock_client. Seit Phase 3 (2026-07-30) laufen auch
|
||||
Faktencheck und Lagebild im EU-Modus komplett ueber Bedrock: der
|
||||
Faktenchecker bekommt vorab eine staan-Stuetzsuche (eu_call_with_search in
|
||||
eu_researcher.py, Haiku plant Verifikations-Queries, Treffer als
|
||||
Kontextblock im Prompt), das Lagebild arbeitet rein auf dem
|
||||
Meldungsbestand. Im EU-Modus gibt es damit KEINE CLI-Aufrufe mehr.
|
||||
Doppelspur: eine Orchestrator-Lane je
|
||||
(Organisation, Backend) — CLI- und EU-Lauf derselben Org laufen gleichzeitig,
|
||||
gleiche Backends seriell. Kosten aus Token x BEDROCK_PRICING plus
|
||||
STAAN_COST_PER_QUERY_USD, Credits-System unveraendert. AWS-Zugang +
|
||||
STAAN_API_KEY liegen in der Staging-.env. boto3 muss im venv installiert
|
||||
sein (beim Promote nach Live auch dort nachziehen!).
|
||||
ki_modelle:
|
||||
schnell: CLAUDE_MODEL_FAST (Haiku) — Feed-Selektion, Topic-Filter, Geoparsing, Uebersetzung, Chat, QC
|
||||
mittel: CLAUDE_MODEL_MEDIUM (Sonnet) — nur Netzwerkanalyse (entity_extractor hat aktuell keinen Aufrufer in der App)
|
||||
@@ -71,11 +93,13 @@ src/:
|
||||
stage_runners.py: "Studio-Bausteine: analyze/factcheck einzeln auf vorhandenem Datenbestand, bewusst NEBEN _run_refresh gebaut"
|
||||
researcher.py: "WebSearch-Recherche (Standard + 4-Phasen-Tiefenrecherche), Feed-/Kanal-/Account-Selektion, Keyword-Extraktion, Google-News-Suchfeeds je Sprache"
|
||||
analyzer.py: "Analyse-Agent (Lagebild/Briefing, Erst- + inkrementell, Inline-Zitate, Topic-Filter, Neueste Entwicklungen, Stimmungsbild)"
|
||||
factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Haiku-Triage ab 25 Fakten), Claim-Matching, Dedup, Schutz gegen Massen-Downgrades"
|
||||
factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Haiku-Triage ab 25 Fakten), Claim-Matching, Dedup, Schutz gegen Massen-Downgrades. Belegtiefe: zaehle_medienhaeuser zaehlt unabhaengige Verlage statt Fundstellen, pruefe_belegtiefe stuft Fakten ab, deren Status mehr Beleg behauptet als vorliegt (confirmed 2, established 3, Primaerbeleg 1), bewertungsregeln() haengt den verbindlichen Regelblock an jeden Auftrag"
|
||||
geoparsing.py: "Haiku-basierte Ortsextraktion, Geocoding offline via geonamescache"
|
||||
entity_extractor.py: "Netzwerkanalyse (Sonnet). ACHTUNG: aktuell ohne Aufrufer in der App, nur regenerate_relations.py im Repo-Root nutzt es"
|
||||
translator.py: "Haiku-Uebersetzung in Batches a 5 (groessere Batches rissen den JSON-Output ab)"
|
||||
claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung, Cancel via ContextVar"
|
||||
claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung, Cancel via ContextVar. Routet je nach _ai_backend_var werkzeuglose Aufrufe zu bedrock_client"
|
||||
bedrock_client.py: "EU-Modellweg ueber AWS Bedrock Converse (nur eu.-Inferenzprofile, Token-zu-USD-Umrechnung ueber BEDROCK_PRICING, gleiche Fehlerkategorien wie das CLI). EU-Umbau Phase 1. Wiederholt Kapazitaets- (503) und Kontingentfehler (429) nach BEDROCK_RETRY_WAITS (5/15/30s), begrenzt durch das Zeitbudget des Aufrufs und BEDROCK_RETRY_BUDGET_S je Refresh; Stoerungen landen im Refresh-Protokoll (refresh_log.error_message) und waehrend der Wartezeit als status_update in der Oberflaeche"
|
||||
eu_researcher.py: "EU-Recherche-Schleife (Phase 2): Bedrock-Modell schlaegt Suchanfragen vor, Code fragt staan, Modell entscheidet weiter/fertig. Trefferaufnahme ueber ein Kontingent je Runde (EU_RESEARCH_MAX_NEW_PER_ROUND) statt eines harten Gesamtstopps, spaetere Runden verdraengen bei vollem Korb die schwaechsten Treffer frueherer Runden; Abbruch nur bei echter Saettigung. Bildet die 4-Phasen-Tiefenrecherche nach, liefert denselben JSON-Array-Text wie die CLI-WebSearch (Einstieg in researcher.search), akzeptiert NUR URLs aus echten staan-Treffern"
|
||||
|
||||
feeds/:
|
||||
rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), adaptive Keyword-Schwelle, Frische-Bonus, Domain-Cap"
|
||||
@@ -91,6 +115,8 @@ src/:
|
||||
source_suggester.py: "KI-Quellen-Vorschlaege via Haiku + Karteileichen-Heuristik"
|
||||
pdf_ingest.py: "Minutenjob: hochgeladene PDFs einlesen (pdfplumber + OCR-Fallback), uebersetzen, als Pool-Artikel ablegen"
|
||||
org_settings.py: "Key-Value-Einstellungen je Organisation (output_language etc.) mit 60s-Cache"
|
||||
media_registry.py: "Kanonische Medien-Identitaet je Domain (registrable_domain, namen_je_domain, Aggregator-Liste, Sprache aus der Adresse). Sorgt dafuer, dass ein Verlag im Bericht unter einem Namen gefuehrt und einmal gezaehlt wird"
|
||||
staan_client.py: "staan.ai Such-Client (EU-Umbau Phase 2): Suche + Volltexte (full_content=markdown) ueber den europaeischen Index, Pflicht-Domain-Ausschlussliste je Anfrage, max 10 Ausschluss-Domains"
|
||||
license_service.py: "Lizenz-Pruefung, Credits-Buchung (charge_usage_to_tenant), Periodenwechsel, Budget-Warnung. expire_licenses() existiert, hat aber KEINEN Scheduler-Job"
|
||||
|
||||
middleware/:
|
||||
@@ -110,7 +136,7 @@ src/:
|
||||
static/:
|
||||
index.html: "Login-Seite (Magic-Link), JS inline"
|
||||
dashboard.html: "Hauptdashboard (Sidebar + Reiter-Ansicht + 8 Modals + Chat-Widget)"
|
||||
studio.html: "Studio (3-Spalten-Werkstatt, online nur fuer info@ sichtbar - Gating rein clientseitig)"
|
||||
studio.html: "Studio (3-Spalten-Werkstatt, online nur fuer info@ sichtbar - Gating rein clientseitig). Reiter Einstellungen ersetzt das Bearbeiten-Modal der klassischen Ansicht und enthaelt die Wahl des KI-Wegs je Fall. Spalte 3 hat genau EINEN Startpunkt (#ls-btn), darunter stehen die vier Schritte als reine Anzeige. Einzelne Bausteine lassen sich aus der Oberflaeche nicht mehr starten, der Server kann es weiterhin."
|
||||
css/:
|
||||
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
|
||||
studio.css: "Studio-spezifische Styles"
|
||||
@@ -118,7 +144,7 @@ src/:
|
||||
api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout, 403 -> Nur-Lese-Modus)"
|
||||
app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt (181 KB)"
|
||||
components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress, Karte (geteilt mit Studio)"
|
||||
studio.js: "Kompletter Studio-Controller (109 KB)"
|
||||
studio.js: "Kompletter Studio-Controller, enthaelt den Einstellungen-Reiter je Fall und die Lauf-Anzeige (_renderLauf, _schrittZustaende, laufKnopf)"
|
||||
chat.js: "Chat-Assistent Widget (Bedienfragen)"
|
||||
pipeline.js: "Grafische Analysepipeline (11 Schritte, Live-Animation)"
|
||||
layout.js: "Reiter-Umschalter je Lage (merkt letzten Reiter; enthaelt noch gridstack-Legacy-Stubs)"
|
||||
@@ -131,6 +157,22 @@ src/:
|
||||
vendor/:
|
||||
leaflet.js: "Karten-Bibliothek"
|
||||
leaflet.markercluster.js: "Marker-Clustering"
|
||||
|
||||
tests/:
|
||||
hinweis: "Laufen ohne Netzzugriff und ohne Kosten, Modell, Suche und Datenbank sind durch Testdoubles ersetzt. Aufruf aus dem Projektstamm, zum Beispiel venv/bin/python tests/test_eu_factcheck.py"
|
||||
test_eu_factcheck.py: "EU-Faktencheck, Nachhak-Runde, Quellenzuordnung, plus Regressionsschutz fuer den Anthropic-Weg"
|
||||
test_eu_belegsuche.py: "Gezielte Belegsuche ueber staan, Planung, Grenzen, Entdopplung, Ausfallverhalten"
|
||||
test_faktenspanne.py: "Richtwert fuer die Zahl der Faktenaussagen, waechst mit der Menge der Meldungen"
|
||||
test_qc_und_runden.py: "Absicherung der Duplikatpruefung und Abbruch der Suchrunden bei erreichter Treffergrenze"
|
||||
test_mehrfachantwort.py: "Antworten, in denen sich das Modell selbst korrigiert und mehrere Fassungen liefert, die ausfuehrlichste muss gewinnen"
|
||||
test_faktencheck_belegtiefe.py: "Belegzaehlung nach Medienhaus, Nachbedingung fuer bestaetigte Fakten, Sperre gegen das Hochstufen von developing, getrennte Bezeichnungen fuer Widerspruch und Widerlegung"
|
||||
test_rss_treffer.py: "Keyword-Matching der RSS-Auswertung: Mehrwort-Begriffe treffen wortweise, Auftrag verlangt Einzelbegriffe und Eigennamen"
|
||||
test_bedrock_wiederholung.py: "Wiederholung des EU-Wegs bei Kapazitaets- und Kontingentfehlern, Staffelung, Zeit- und Wartebudget, Sichtbarkeit im Refresh-Protokoll"
|
||||
test_sammelaktionen.js: "Sammelaktionen der Seitenleiste im klassischen Dashboard, Auswahlmodus, Filterbeachtung, Loeschbestaetigung (Aufruf mit node)"
|
||||
test_falleinstellungen.js: "Einstellungen-Reiter im Studio, Formular fuellen, nur Geaendertes speichern, KI-Weg umstellen samt Warnhinweis, Abo nachladen (Aufruf mit node)"
|
||||
test_studio_struktur.py: "Strukturpruefung beider Oberflaechen ohne Browser, eindeutige IDs, jedes onclick zeigt auf eine vorhandene Methode, Cache-Buster, Design-Tokens"
|
||||
test_lauf.js: "Lauf-Anzeige der Studio-Spalte, Zustand des Startknopfs, Kette, Schrittzustaende aus /freshness, Nachweis dass kein Schritt etwas startet (Aufruf mit node)"
|
||||
test_quellenausgabe.py: "Ausgabetreue des Quellenverzeichnisses: Nummern aus dem Lagebild statt Zeilennummern, keine Kappung, ein Verlag ein Name, Statistik deckungsgleich mit dem Verzeichnis"
|
||||
```
|
||||
|
||||
## Architektur
|
||||
@@ -141,13 +183,13 @@ incident_typen:
|
||||
label: "Live-Monitoring"
|
||||
quellen: "RSS + WebSearch + optional Telegram"
|
||||
analyse: "Fliesstext-Lagebild"
|
||||
faktencheck_status: "confirmed/unconfirmed/contradicted/developing"
|
||||
faktencheck_status: "confirmed/unconfirmed/contradicted/false/developing (contradicted = Belege widersprechen einander, false = nachweislich widerlegt)"
|
||||
refresh: "Manuell oder automatisch (Intervall konfigurierbar)"
|
||||
research:
|
||||
label: "Recherche"
|
||||
quellen: "Nur WebSearch 4-Phasen-Tiefenrecherche (kein RSS)"
|
||||
analyse: "Strukturiertes Briefing (Ueberblick, Hintergrund, Akteure, Lage, Einschaetzung, Quellenqualitaet)"
|
||||
faktencheck_status: "established/unverified/disputed/developing"
|
||||
faktencheck_status: "established/unverified/disputed/false/developing"
|
||||
refresh: "Immer manuell, erster Refresh automatisch 3 Durchlaeufe (Multi-Pass)"
|
||||
multi_pass:
|
||||
durchlaeufe: 3
|
||||
@@ -257,6 +299,7 @@ ui_sync:
|
||||
- "Lokal: X-Zugänge-Oberfläche (twscrape) in Sidebar/Modal/Quellenübersicht. Online bewusst nicht vorhanden (kein x-Router im Online-Backend)."
|
||||
- "Lokal: Auto-Login über /api/auth/dev-login (Demo-Modus). Online ausschließlich Magic-Link."
|
||||
- "Lokal: Kostenvorschau im Anlege-Dialog (updateIntervalCostHint, Fork-Commit 5b0b578). Online noch nicht portiert."
|
||||
- "Online: Umschalter 'KI-Verarbeitung' im Anlege-/Bearbeiten-Dialog (Auswahl inc-ai-backend: Standard/Anthropic/EU) plus EU-Kennzeichen in der Lagen-Kopfzeile (incident-eu-badge). Bewusst NICHT im Lokal-Fork, dort gibt es kein Bedrock-Backend und das Feld waere funktionslos (Stand 2026-07-30)."
|
||||
bekannter_drift_stand_2026_07_26:
|
||||
- "Die Takt-Untergrenze ist inzwischen AUCH online (dort _getMinIntervalMinutes, 30 Min; lokal _intervalMinMinutes mit Vorgabe 12 Std). Ältere Angaben, sie fehle online, sind überholt."
|
||||
- "Online-only, noch nicht in den Fork portiert: Reiter 'Öffentliche Stimmung' (renderPublicMood) und die Fall-Chat-Rückfrage /clarify."
|
||||
@@ -268,23 +311,6 @@ ui_sync:
|
||||
Plausibilität prüfen. Ein Riesen-Diff ist fast immer ein Zeilenenden-Unfall.
|
||||
```
|
||||
|
||||
## Changelog-Workflow
|
||||
|
||||
Bei JEDER Aenderung am Monitor muessen zwei Dinge passieren:
|
||||
|
||||
1. **TaskMate Wissensdatenbank** (Kategorie: "Changelog Monitor", category_id=31):
|
||||
|
||||
|
||||
2. **Git Commit + Push zu Gitea**
|
||||
|
||||
Changelog-Kategorien in TaskMate:
|
||||
- 31 = Changelog Monitor
|
||||
- 32 = Changelog Globe
|
||||
- 33 = Changelog Netzwerkanalyse
|
||||
- 34 = Changelog Verwaltung
|
||||
- 35 = Changelog Website
|
||||
- 36 = Changelog TaskMate
|
||||
|
||||
## Staging-Umgebung
|
||||
|
||||
```yaml
|
||||
@@ -315,6 +341,8 @@ staging:
|
||||
STAGING_MODE: 1 (unlimited_budget, kein Credits-Hard-Stop)
|
||||
TRANSLATOR_ENABLED: false
|
||||
TELEGRAM_SESSION_PATH: ~/.telegram-staging/ (eigene Session, dort ist NIEMAND eingeloggt - Telegram-Recherche auf Staging liefert nichts, die Status-Karte im Portal zeigt das ehrlich an)
|
||||
AI_BACKEND: "(optional) 'cli' oder 'bedrock', globaler Default fuer den EU-Modellweg. Ohne Eintrag gilt 'cli'"
|
||||
AWS_ACCESS_KEY_ID_usw: "AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION=eu-central-1 und STAAN_API_KEY liegen seit 2026-07-29 in der Staging-.env (IAM-Benutzer nur fuer Bedrock, staan-Suchschluessel fuer Phase 2)"
|
||||
|
||||
auth_service:
|
||||
pfad: /opt/aegis-staging-auth
|
||||
|
||||
@@ -11,6 +11,8 @@ python-multipart
|
||||
aiosmtplib
|
||||
geonamescache>=2.0
|
||||
telethon
|
||||
# AWS Bedrock, EU-Modellweg (agents/bedrock_client.py, EU-Umbau Phase 1)
|
||||
boto3>=1.34
|
||||
# X/Twitter-Scraper (feeds/x_parser.py)
|
||||
twscrape @ git+https://github.com/vladkens/twscrape.git@206f0942fe41149da28530399f7c772ec00be17a
|
||||
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
||||
|
||||
@@ -37,6 +37,8 @@ REGELN:
|
||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
||||
- Nur gesicherte Informationen in die Zusammenfassung
|
||||
- Bei widersprüchlichen Angaben beide Seiten erwähnen
|
||||
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
|
||||
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
|
||||
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
|
||||
- Quellen immer mit [Nr] referenzieren
|
||||
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
|
||||
@@ -89,6 +91,8 @@ REGELN:
|
||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
||||
- Nur gesicherte Informationen verwenden
|
||||
- Bei widersprüchlichen Angaben beide Seiten erwähnen
|
||||
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
|
||||
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
|
||||
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
|
||||
- Quellen immer mit [Nr] referenzieren
|
||||
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
|
||||
@@ -140,6 +144,8 @@ REGELN:
|
||||
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
||||
- Bei widersprüchlichen Angaben beide Seiten erwähnen
|
||||
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
|
||||
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
|
||||
- Falls das BISHERIGE LAGEBILD Umschreibungen enthält (ae, oe, ue, ss anstelle von ä, ö, ü, ß), ersetze diese beim Aktualisieren durch echte Umlaute. Die Regel "echte UTF-8-Umlaute" hat Vorrang vor der Regel "bestehende Formulierungen beibehalten".
|
||||
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
|
||||
- Quellen immer mit [Nr] referenzieren
|
||||
@@ -235,6 +241,8 @@ REGELN zur Formulierung:
|
||||
- Wenn eine Quelle eine erkennbare politische Ausrichtung hat (pro-russisch, staatsnah, rechtsextrem etc.), im Bullet-Text erwähnen ("laut pro-russischem Telegram-Kanal Rybar...").
|
||||
- KEINE Gedankenstriche (—, –). Stattdessen Kommas, Doppelpunkte, neue Sätze.
|
||||
- Bei widersprüchlichen Angaben beide Seiten knapp nennen.
|
||||
- Korrigierte Zahlen als Verlauf, nicht als Spanne ("Zahl der Toten steigt auf 67" statt "57 bis 67 Tote").
|
||||
- Wertende Wörter nur, wenn die Meldung genau diese Wertung trägt (nicht "freiwillig zurückgekehrt", wenn die Rückkehr nach behördlicher Aufforderung erfolgte).
|
||||
- KEINE Einleitung, KEINE Überschrift, KEINE Nachbemerkungen.
|
||||
|
||||
OUTPUT-FORMAT (ausschliesslich, kein Code-Fence, JEDE Zeile beginnt mit "- "):
|
||||
@@ -279,6 +287,8 @@ _FACT_STATUS_GROUPS = [
|
||||
{"confirmed", "established"}),
|
||||
("Umstritten (Quellen widersprechen sich oder Faktencheck hat Widersprüche dokumentiert):",
|
||||
{"contradicted", "disputed"}),
|
||||
("Widerlegt (ein Beleg entkräftet die Aussage, sie darf im Lagebild nicht als Tatsache stehen):",
|
||||
{"false"}),
|
||||
("Unbestätigt (nur eine einzelne Quelle, eine unabhängige Bestätigung steht aus):",
|
||||
{"unconfirmed", "unverified"}),
|
||||
("In Entwicklung (laufender Sachverhalt, Stand offen):",
|
||||
@@ -287,7 +297,7 @@ _FACT_STATUS_GROUPS = [
|
||||
|
||||
_FACT_STATUS_PRIORITY = {
|
||||
"confirmed": 5, "established": 5,
|
||||
"contradicted": 4, "disputed": 4,
|
||||
"contradicted": 4, "disputed": 4, "false": 4,
|
||||
"unconfirmed": 3, "unverified": 3,
|
||||
"developing": 1,
|
||||
}
|
||||
@@ -419,7 +429,16 @@ class AnalyzerAgent:
|
||||
)
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
# EU-Modus (Phase 3): Lagebild rein auf dem Meldungsbestand, über Bedrock.
|
||||
# Die EU-Recherche hat unmittelbar davor gesammelt, eigene Websuche
|
||||
# braucht die Analyse deshalb nicht.
|
||||
from agents.claude_client import _ai_backend_var
|
||||
from config import AI_BACKEND
|
||||
if (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock":
|
||||
from agents.eu_researcher import eu_call_with_search
|
||||
result, usage = await eu_call_with_search(prompt, output_language=output_language)
|
||||
else:
|
||||
result, usage = await call_claude(prompt)
|
||||
analysis = self._parse_response(result)
|
||||
if analysis:
|
||||
analysis = self._sanitize_sources(analysis)
|
||||
@@ -484,7 +503,14 @@ class AnalyzerAgent:
|
||||
)
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
# EU-Modus (Phase 3): wie bei der Erstanalyse, werkzeuglos über Bedrock.
|
||||
from agents.claude_client import _ai_backend_var
|
||||
from config import AI_BACKEND
|
||||
if (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock":
|
||||
from agents.eu_researcher import eu_call_with_search
|
||||
result, usage = await eu_call_with_search(prompt, output_language=output_language)
|
||||
else:
|
||||
result, usage = await call_claude(prompt)
|
||||
analysis = self._parse_response(result)
|
||||
if analysis:
|
||||
analysis = self._sanitize_sources(analysis)
|
||||
@@ -1092,6 +1118,28 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Versuch 2b: Nicht maskierte Anfuehrungszeichen im Text reparieren.
|
||||
# Haeufigster Bruch (deutsche Zitate mit geradem Schlusszeichen); ohne
|
||||
# diesen Schritt rettete der Regex-Fallback unten nur ein Bruchstueck
|
||||
# und das Lagebild brach still mitten im Satz ab.
|
||||
# Enthaelt die Antwort mehrere Objekte, weil das Modell sich selbst
|
||||
# korrigiert hat ("Let me redo this properly with all sections"), zaehlt
|
||||
# das ausfuehrlichste. Sonst landete nur die erste, kurze Fassung in der
|
||||
# Datenbank und der Bericht verlor fuenf von sechs Abschnitten.
|
||||
from json_utils import extract_json_objects as _forgiving_objects
|
||||
kandidaten = [o for o in _forgiving_objects(response)
|
||||
if isinstance(o, dict) and "summary" in o]
|
||||
if kandidaten:
|
||||
bester = max(kandidaten, key=lambda o: len(str(o.get("summary") or "")))
|
||||
if len(kandidaten) > 1:
|
||||
logger.warning(
|
||||
"Analyse enthielt %d Berichtsfassungen, nehme die ausfuehrlichste "
|
||||
"(%d statt %d Zeichen)",
|
||||
len(kandidaten), len(str(bester.get("summary") or "")),
|
||||
len(str(kandidaten[0].get("summary") or "")),
|
||||
)
|
||||
return bester
|
||||
|
||||
# Versuch 3: Abgeschnittenes JSON reparieren (haeufig bei langen Antworten)
|
||||
candidate = match.group() if match else cleaned
|
||||
repaired = self._repair_truncated_json(candidate)
|
||||
@@ -1101,7 +1149,18 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn
|
||||
# Versuch 4: summary per Regex extrahieren als letzter Fallback
|
||||
fallback = self._extract_summary_fallback(response)
|
||||
if fallback:
|
||||
logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
|
||||
from json_utils import looks_truncated as _looks_truncated
|
||||
gerettet = fallback.get("summary", "")
|
||||
if _looks_truncated(gerettet):
|
||||
# Sichtbar machen statt still hinnehmen: hier geht Inhalt verloren.
|
||||
logger.error(
|
||||
"JSON-Parse fehlgeschlagen und Regex-Fallback liefert ein "
|
||||
"abgeschnittenes Lagebild (%d von vermutlich mehr Zeichen). "
|
||||
"Antwortlaenge %d, Ende: %r",
|
||||
len(gerettet), len(response), gerettet[-80:],
|
||||
)
|
||||
else:
|
||||
logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
|
||||
return fallback
|
||||
|
||||
logger.error(
|
||||
|
||||
447
src/agents/bedrock_client.py
Normale Datei
447
src/agents/bedrock_client.py
Normale Datei
@@ -0,0 +1,447 @@
|
||||
"""Bedrock-Client. Zweiter Modellweg über AWS Bedrock (EU-Inferenzprofile, Frankfurt).
|
||||
|
||||
Teil des EU/DSGVO-Umbaus (Phase 1). Die Anfragen werden vollständig von AWS in
|
||||
europäischen Regionen verarbeitet, nichts geht an Anthropic. In Phase 1 bedient
|
||||
dieser Weg nur werkzeuglose Aufrufe (tools=None). Aufrufe mit WebSearch/WebFetch
|
||||
laufen weiter über das Claude CLI, bis die staan-Recherche-Schleife (Phase 2)
|
||||
fertig ist. Das Routing sitzt zentral in claude_client.call_claude.
|
||||
|
||||
Kosten. Bedrock meldet nur Token. Die Umrechnung in USD passiert hier über die
|
||||
Preistabelle BEDROCK_PRICING aus config.py, damit ClaudeUsage.cost_usd gefüllt
|
||||
bleibt und Statistik und Credits-Abrechnung unverändert funktionieren.
|
||||
"""
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import time
|
||||
|
||||
from config import (
|
||||
BEDROCK_CACHE_READ_FACTOR,
|
||||
BEDROCK_CACHE_WRITE_FACTOR,
|
||||
BEDROCK_MAX_TOKENS,
|
||||
BEDROCK_MODEL_MAP,
|
||||
BEDROCK_PRICING,
|
||||
BEDROCK_REGION,
|
||||
BEDROCK_RETRY_BUDGET_S,
|
||||
BEDROCK_RETRY_MIN_REST_S,
|
||||
BEDROCK_RETRY_WAITS,
|
||||
CLAUDE_MODEL_STANDARD,
|
||||
CLAUDE_TIMEOUT,
|
||||
)
|
||||
from agents.claude_client import (
|
||||
ClaudeCliError,
|
||||
ClaudeUsage,
|
||||
_cancel_event_var,
|
||||
_sanitize_mdash,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("osint.bedrock_client")
|
||||
|
||||
# Entspricht dem JSON-Zwang des CLI-Wegs bei tools=None (siehe claude_client.call_claude)
|
||||
_JSON_ONLY_SYSTEM = (
|
||||
"CRITICAL: You are a JSON-only output agent. "
|
||||
"Output EXCLUSIVELY a single valid JSON object. "
|
||||
"No explanatory text, no markdown fences, no continuation of previous responses. "
|
||||
"Start your response with { and end with }."
|
||||
)
|
||||
|
||||
# botocore-Fehlercodes, gruppiert nach den error_type-Kategorien des CLI-Wegs,
|
||||
# damit die Retry-Steuerung des Orchestrators unverändert greift.
|
||||
_RATE_LIMIT_CODES = {
|
||||
"ThrottlingException",
|
||||
"TooManyRequestsException",
|
||||
"ServiceUnavailableException",
|
||||
"ServiceQuotaExceededException",
|
||||
"ModelNotReadyException",
|
||||
}
|
||||
# Innerhalb der Kategorie 'rate_limit' zwei verschiedene Ursachen, die nach
|
||||
# aussen gleich behandelt werden (damit die Retry-Steuerung des Orchestrators
|
||||
# unveraendert greift), im Log aber auseinandergehalten gehoeren:
|
||||
# Kapazitaet heisst, AWS hat gerade keine freie Rechenzeit fuer das Modell.
|
||||
# Kontingent heisst, WIR haben zu viele Anfragen je Minute gestellt.
|
||||
_KAPAZITAETS_CODES = {"ServiceUnavailableException", "ModelNotReadyException"}
|
||||
_KONTINGENT_CODES = {
|
||||
"ThrottlingException",
|
||||
"TooManyRequestsException",
|
||||
"ServiceQuotaExceededException",
|
||||
}
|
||||
_AUTH_ERROR_CODES = {
|
||||
"AccessDeniedException",
|
||||
"UnrecognizedClientException",
|
||||
"ExpiredTokenException",
|
||||
"InvalidSignatureException",
|
||||
}
|
||||
# Transiente botocore-Netzfehler, die als TimeoutError hochgereicht werden
|
||||
# (der Orchestrator behandelt TimeoutError/ConnectionError als retry-fähig).
|
||||
_TRANSIENT_EXC_NAMES = (
|
||||
"ReadTimeoutError",
|
||||
"ConnectTimeoutError",
|
||||
"EndpointConnectionError",
|
||||
"ConnectionClosedError",
|
||||
)
|
||||
|
||||
_client = None
|
||||
|
||||
# Klartext je Stoerungsart, genutzt in Log, Refresh-Protokoll und Oberflaeche.
|
||||
_ART_TEXT = {
|
||||
"kapazitaet": "hat keine freie Kapazität",
|
||||
"kontingent": "meldet ausgeschöpftes Kontingent",
|
||||
"verbindung": "nicht erreichbar",
|
||||
}
|
||||
|
||||
# --- Wartebudget und Sichtbarkeit je Refresh --------------------------------
|
||||
# Alle drei Variablen gelten fuer den laufenden Refresh (ContextVar, also je
|
||||
# Task getrennt). Ohne gesetzten Kontext verhaelt sich der Client wie bisher,
|
||||
# nur mit Wiederholung: das Budget startet dann beim Standardwert und die
|
||||
# Meldungen gehen ausschliesslich ins Log.
|
||||
_wartebudget_var: contextvars.ContextVar[list] = contextvars.ContextVar("bedrock_wartebudget")
|
||||
_stoerungen_var: contextvars.ContextVar[list] = contextvars.ContextVar("bedrock_stoerungen")
|
||||
# Rueckmeldung an die Oberflaeche waehrend einer Wartezeit. Der Orchestrator
|
||||
# setzt hier eine Funktion (art, sekunden) -> None, damit die Pipeline-Anzeige
|
||||
# nicht wie ein Haenger aussieht.
|
||||
_wartemelder_var: contextvars.ContextVar = contextvars.ContextVar("bedrock_wartemelder")
|
||||
|
||||
|
||||
def refresh_kontext_starten() -> None:
|
||||
"""Setzt Wartebudget und Stoerungsliste fuer einen neuen Refresh zurueck."""
|
||||
_wartebudget_var.set([BEDROCK_RETRY_BUDGET_S])
|
||||
_stoerungen_var.set([])
|
||||
|
||||
|
||||
def stoerungen() -> list[dict]:
|
||||
"""Stoerungen des laufenden Refreshs, aelteste zuerst."""
|
||||
return list(_stoerungen_var.get([]) or [])
|
||||
|
||||
|
||||
def stoerungen_zusammenfassen() -> str:
|
||||
"""Einzeiler ueber die Stoerungen des Laufs, leer wenn es keine gab.
|
||||
|
||||
Landet im Refresh-Protokoll, damit ein Lauf mit Aussetzern nicht wie ein
|
||||
vollstaendiger aussieht.
|
||||
"""
|
||||
eintraege = stoerungen()
|
||||
if not eintraege:
|
||||
return ""
|
||||
je_art: dict[str, int] = {}
|
||||
gewartet = 0.0
|
||||
for e in eintraege:
|
||||
je_art[e["art"]] = je_art.get(e["art"], 0) + 1
|
||||
gewartet += e["wartezeit"]
|
||||
teile = [f"{anzahl}x {_ART_TEXT.get(art, art)}" for art, anzahl in sorted(je_art.items())]
|
||||
return f"KI-Dienst: {', '.join(teile)}, insgesamt {gewartet:.0f}s gewartet"
|
||||
|
||||
|
||||
def wartemelder_setzen(melder) -> None:
|
||||
"""Hinterlegt die Rueckmeldung an die Oberflaeche fuer diesen Refresh."""
|
||||
_wartemelder_var.set(melder)
|
||||
|
||||
|
||||
def _budget_rest() -> float:
|
||||
behaelter = _wartebudget_var.get(None)
|
||||
if behaelter is None:
|
||||
behaelter = [BEDROCK_RETRY_BUDGET_S]
|
||||
_wartebudget_var.set(behaelter)
|
||||
return behaelter[0]
|
||||
|
||||
|
||||
def _budget_verbrauchen(sekunden: float) -> None:
|
||||
behaelter = _wartebudget_var.get(None)
|
||||
if behaelter is not None:
|
||||
behaelter[0] = max(0.0, behaelter[0] - sekunden)
|
||||
|
||||
|
||||
def merke_stoerung(art: str, modell: str, wartezeit: float) -> None:
|
||||
"""Haelt eine wiederholte Stoerung fuer das Refresh-Protokoll fest."""
|
||||
liste = _stoerungen_var.get(None)
|
||||
if liste is None:
|
||||
liste = []
|
||||
_stoerungen_var.set(liste)
|
||||
liste.append({"art": art, "modell": modell, "wartezeit": wartezeit})
|
||||
|
||||
|
||||
def _melde_wartezeit(art: str, sekunden: float) -> None:
|
||||
"""Meldet eine laufende Wartezeit an die Oberflaeche, wenn eingerichtet."""
|
||||
melder = _wartemelder_var.get(None)
|
||||
if melder is None:
|
||||
return
|
||||
try:
|
||||
melder(art, sekunden)
|
||||
except Exception as e: # Anzeige darf den Lauf nie gefaehrden
|
||||
logger.debug("Wartemeldung fehlgeschlagen: %s", e)
|
||||
|
||||
|
||||
def _get_client():
|
||||
"""Erzeugt den bedrock-runtime-Client einmalig. Lazy Import, damit die App
|
||||
auch ohne installiertes boto3 startet, solange das Backend 'cli' ist."""
|
||||
global _client
|
||||
if _client is None:
|
||||
try:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
except ImportError as e:
|
||||
raise ClaudeCliError(
|
||||
"cli_error",
|
||||
f"boto3 ist nicht installiert, Bedrock-Backend nicht nutzbar ({e})",
|
||||
)
|
||||
_client = boto3.client(
|
||||
"bedrock-runtime",
|
||||
region_name=BEDROCK_REGION,
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=CLAUDE_TIMEOUT,
|
||||
# 'adaptive' bremst sich bei Drosselung selbst ein, statt
|
||||
# stur nachzuschieben. Die Versuchszahl bleibt klein, weil das
|
||||
# geduldige Warten in call_bedrock sitzt: botocore deckt die
|
||||
# Sekunden ab, unsere Schleife die Zehnersekunden.
|
||||
retries={"max_attempts": 3, "mode": "adaptive"},
|
||||
),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _fehlercode(exc: Exception) -> str:
|
||||
"""Botocore-Fehlercode einer Exception, leer wenn keiner vorliegt."""
|
||||
response = getattr(exc, "response", None)
|
||||
if isinstance(response, dict):
|
||||
return response.get("Error", {}).get("Code", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_bedrock_error(exc: Exception) -> str:
|
||||
"""Ordnet einer botocore-Exception eine error_type-Kategorie zu."""
|
||||
code = _fehlercode(exc)
|
||||
if code in _RATE_LIMIT_CODES:
|
||||
return "rate_limit"
|
||||
if code in _AUTH_ERROR_CODES:
|
||||
return "auth_error"
|
||||
return "cli_error"
|
||||
|
||||
|
||||
def stoerungsart(exc: Exception) -> str | None:
|
||||
"""Benennt die Ursache einer wiederholbaren Stoerung, sonst None.
|
||||
|
||||
'kapazitaet' = AWS hat keine freie Rechenzeit fuer das Modell (HTTP 503).
|
||||
'kontingent' = unsere Anfragen je Minute sind ausgeschoepft (HTTP 429).
|
||||
"""
|
||||
code = _fehlercode(exc)
|
||||
if code in _KAPAZITAETS_CODES:
|
||||
return "kapazitaet"
|
||||
if code in _KONTINGENT_CODES:
|
||||
return "kontingent"
|
||||
if type(exc).__name__ in _TRANSIENT_EXC_NAMES:
|
||||
return "verbindung"
|
||||
return None
|
||||
|
||||
|
||||
def _cost_usd(
|
||||
cli_model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cache_write: int,
|
||||
cache_read: int,
|
||||
) -> float:
|
||||
"""Token mal Preis. Preise in BEDROCK_PRICING sind USD je 1 Mio Token."""
|
||||
prices = BEDROCK_PRICING.get(cli_model)
|
||||
if not prices:
|
||||
logger.warning(f"Keine Preise für Modell '{cli_model}' in BEDROCK_PRICING, cost_usd bleibt 0")
|
||||
return 0.0
|
||||
per_in = prices["input"] / 1_000_000
|
||||
per_out = prices["output"] / 1_000_000
|
||||
return (
|
||||
input_tokens * per_in
|
||||
+ output_tokens * per_out
|
||||
+ cache_write * per_in * BEDROCK_CACHE_WRITE_FACTOR
|
||||
+ cache_read * per_in * BEDROCK_CACHE_READ_FACTOR
|
||||
)
|
||||
|
||||
|
||||
async def call_bedrock(
|
||||
prompt: str,
|
||||
model: str | None = None,
|
||||
raw_text: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[str, ClaudeUsage]:
|
||||
"""Ruft Claude über AWS Bedrock (Converse API) auf. Gibt (result_text, usage) zurück.
|
||||
|
||||
Gleicher Vertrag wie call_claude mit tools=None. Fehler kommen als
|
||||
ClaudeCliError mit denselben error_type-Kategorien, Timeouts als
|
||||
TimeoutError, Abbrüche als CancelledError.
|
||||
|
||||
Bei Kapazitäts- und Kontingentfehlern wird nach BEDROCK_RETRY_WAITS erneut
|
||||
versucht, begrenzt durch das Zeitbudget dieses Aufrufs und das
|
||||
Wartebudget des laufenden Refreshs.
|
||||
"""
|
||||
cli_model = model or CLAUDE_MODEL_STANDARD
|
||||
bedrock_model = BEDROCK_MODEL_MAP.get(cli_model)
|
||||
if not bedrock_model:
|
||||
raise ClaudeCliError(
|
||||
"cli_error",
|
||||
f"Kein EU-Inferenzprofil für Modell '{cli_model}' in BEDROCK_MODEL_MAP hinterlegt",
|
||||
)
|
||||
if not bedrock_model.startswith("eu."):
|
||||
# Sperre gegen Nicht-EU-Profile. Ein globales Profil würde die
|
||||
# Verarbeitung ausserhalb Europas erlauben, genau das soll dieser Weg
|
||||
# ausschliessen.
|
||||
raise ClaudeCliError(
|
||||
"cli_error",
|
||||
f"Bedrock-Profil '{bedrock_model}' ist kein EU-Profil (eu.-Präfix fehlt)",
|
||||
)
|
||||
|
||||
effective_timeout = timeout if timeout is not None else CLAUDE_TIMEOUT
|
||||
cancel_event = _cancel_event_var.get(None)
|
||||
if cancel_event and cancel_event.is_set():
|
||||
raise asyncio.CancelledError("Cancel angefordert")
|
||||
|
||||
client = _get_client()
|
||||
|
||||
kwargs = {
|
||||
"modelId": bedrock_model,
|
||||
"messages": [{"role": "user", "content": [{"text": prompt}]}],
|
||||
"inferenceConfig": {"maxTokens": BEDROCK_MAX_TOKENS},
|
||||
}
|
||||
if not raw_text:
|
||||
kwargs["system"] = [{"text": _JSON_ONLY_SYSTEM}]
|
||||
|
||||
beginn = time.monotonic()
|
||||
versuch = 0
|
||||
while True:
|
||||
try:
|
||||
response = await _converse_einmal(
|
||||
client, kwargs, effective_timeout - (time.monotonic() - beginn), cancel_event
|
||||
)
|
||||
break
|
||||
except (asyncio.CancelledError, TimeoutError):
|
||||
raise
|
||||
except Exception as e:
|
||||
art = stoerungsart(e)
|
||||
wartezeit = _wartezeit(
|
||||
art, versuch, rest=effective_timeout - (time.monotonic() - beginn)
|
||||
)
|
||||
if wartezeit is None:
|
||||
_protokolliere_fehler(e, cli_model, versuch)
|
||||
if type(e).__name__ in _TRANSIENT_EXC_NAMES:
|
||||
raise TimeoutError(f"Bedrock Verbindungsfehler ({type(e).__name__}). {e}") from e
|
||||
raise ClaudeCliError(
|
||||
_classify_bedrock_error(e), f"{type(e).__name__}. {e}"
|
||||
) from e
|
||||
versuch += 1
|
||||
_budget_verbrauchen(wartezeit)
|
||||
merke_stoerung(art, cli_model, wartezeit)
|
||||
logger.warning(
|
||||
"Bedrock %s (%s, %s): Versuch %d in %.0fs",
|
||||
_ART_TEXT.get(art, art), type(e).__name__, cli_model, versuch + 1, wartezeit,
|
||||
)
|
||||
_melde_wartezeit(art, wartezeit)
|
||||
await asyncio.sleep(wartezeit)
|
||||
if cancel_event and cancel_event.is_set():
|
||||
raise asyncio.CancelledError("Cancel angefordert")
|
||||
|
||||
return _auswerten(response, cli_model, bedrock_model)
|
||||
|
||||
|
||||
def _wartezeit(art: str | None, versuch: int, rest: float) -> float | None:
|
||||
"""Wartezeit vor dem nächsten Versuch, None wenn nicht wiederholt wird.
|
||||
|
||||
Drei Bedingungen müssen zusammenkommen: die Störung ist wiederholbar, es
|
||||
gibt noch eine Wartestufe, und die Wartezeit passt sowohl in das
|
||||
Zeitbudget dieses Aufrufs als auch in das Wartebudget des Refreshs.
|
||||
"""
|
||||
if not art or versuch >= len(BEDROCK_RETRY_WAITS):
|
||||
return None
|
||||
wartezeit = BEDROCK_RETRY_WAITS[versuch]
|
||||
if rest < wartezeit + BEDROCK_RETRY_MIN_REST_S:
|
||||
logger.warning(
|
||||
"Bedrock: keine Wiederholung, Zeitbudget des Aufrufs reicht nicht "
|
||||
"(%.0fs übrig, %.0fs Wartezeit plus %.0fs Reserve nötig)",
|
||||
max(rest, 0), wartezeit, BEDROCK_RETRY_MIN_REST_S,
|
||||
)
|
||||
return None
|
||||
if _budget_rest() < wartezeit:
|
||||
logger.warning(
|
||||
"Bedrock: keine Wiederholung, Wartebudget des Laufs erschöpft (%.0fs übrig)",
|
||||
_budget_rest(),
|
||||
)
|
||||
return None
|
||||
return wartezeit
|
||||
|
||||
|
||||
def _protokolliere_fehler(e: Exception, cli_model: str, versuch: int) -> None:
|
||||
"""Schreibt den endgültigen Fehler mit seiner Ursache ins Log."""
|
||||
exc_name = type(e).__name__
|
||||
zusatz = f" nach {versuch + 1} Versuchen" if versuch else ""
|
||||
art = stoerungsart(e)
|
||||
if art:
|
||||
logger.warning(
|
||||
"Bedrock %s (%s, %s)%s. %s", _ART_TEXT.get(art, art), exc_name, cli_model, zusatz, e
|
||||
)
|
||||
elif _classify_bedrock_error(e) == "auth_error":
|
||||
logger.error("Bedrock Auth-Fehler (%s). %s", exc_name, e)
|
||||
else:
|
||||
logger.error("Bedrock-Fehler (%s). %s", exc_name, e)
|
||||
|
||||
|
||||
async def _converse_einmal(client, kwargs: dict, rest_timeout: float, cancel_event):
|
||||
"""Führt genau einen Converse-Aufruf aus und gibt die Antwort zurück."""
|
||||
if rest_timeout <= 0:
|
||||
raise TimeoutError("Bedrock Timeout: Zeitbudget vor dem Aufruf aufgebraucht")
|
||||
|
||||
call_task = asyncio.create_task(asyncio.to_thread(client.converse, **kwargs))
|
||||
wait_tasks = [call_task]
|
||||
cancel_wait_task = None
|
||||
if cancel_event:
|
||||
cancel_wait_task = asyncio.create_task(cancel_event.wait())
|
||||
wait_tasks.append(cancel_wait_task)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
wait_tasks, timeout=rest_timeout, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
|
||||
if call_task not in done:
|
||||
# Der HTTP-Aufruf im Thread lässt sich nicht hart abbrechen, er läuft
|
||||
# aus und sein Ergebnis wird verworfen. Der Callback verhindert
|
||||
# "exception was never retrieved"-Warnungen.
|
||||
call_task.add_done_callback(
|
||||
lambda t: t.exception() if not t.cancelled() else None
|
||||
)
|
||||
if cancel_wait_task is not None and cancel_wait_task in done:
|
||||
raise asyncio.CancelledError("Cancel angefordert")
|
||||
raise TimeoutError(f"Bedrock Timeout nach {rest_timeout:.0f}s")
|
||||
|
||||
# Fehler werden hier NICHT behandelt, das entscheidet die Wiederholung in
|
||||
# call_bedrock anhand der Stoerungsart.
|
||||
return call_task.result()
|
||||
|
||||
|
||||
def _auswerten(response: dict, cli_model: str, bedrock_model: str) -> tuple[str, ClaudeUsage]:
|
||||
"""Wandelt die Converse-Antwort in (Text, Verbrauch) um."""
|
||||
out_msg = response.get("output", {}).get("message", {})
|
||||
parts = [c.get("text", "") for c in out_msg.get("content", []) if "text" in c]
|
||||
result_text = "".join(parts).strip()
|
||||
|
||||
stop_reason = response.get("stopReason", "")
|
||||
if stop_reason == "max_tokens":
|
||||
logger.warning(
|
||||
f"Bedrock-Antwort bei maxTokens={BEDROCK_MAX_TOKENS} abgeschnitten (Modell {cli_model})"
|
||||
)
|
||||
|
||||
u = response.get("usage", {})
|
||||
input_tokens = int(u.get("inputTokens", 0) or 0)
|
||||
output_tokens = int(u.get("outputTokens", 0) or 0)
|
||||
cache_write = int(u.get("cacheWriteInputTokens", 0) or 0)
|
||||
cache_read = int(u.get("cacheReadInputTokens", 0) or 0)
|
||||
usage = ClaudeUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_creation_tokens=cache_write,
|
||||
cache_read_tokens=cache_read,
|
||||
cost_usd=_cost_usd(cli_model, input_tokens, output_tokens, cache_write, cache_read),
|
||||
duration_ms=int(response.get("metrics", {}).get("latencyMs", 0) or 0),
|
||||
)
|
||||
logger.info(
|
||||
f"Bedrock [{cli_model} -> {bedrock_model}]: {usage.input_tokens} in / "
|
||||
f"{usage.output_tokens} out / cache {usage.cache_creation_tokens}+{usage.cache_read_tokens} / "
|
||||
f"${usage.cost_usd:.4f} / {usage.duration_ms}ms"
|
||||
)
|
||||
return _sanitize_mdash(result_text), usage
|
||||
@@ -4,12 +4,17 @@ import contextvars
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from config import CLAUDE_PATH, CLAUDE_TIMEOUT, CLAUDE_MODEL_FAST, CLAUDE_MODEL_STANDARD
|
||||
from config import CLAUDE_PATH, CLAUDE_TIMEOUT, CLAUDE_MODEL_FAST, CLAUDE_MODEL_STANDARD, AI_BACKEND
|
||||
|
||||
# ContextVar fuer Cancel-Event: Wird vom Orchestrator gesetzt,
|
||||
# call_claude prueft automatisch darauf -- kein Durchreichen noetig.
|
||||
_cancel_event_var: contextvars.ContextVar[asyncio.Event | None] = contextvars.ContextVar("_cancel_event_var", default=None)
|
||||
|
||||
# ContextVar für das KI-Backend des laufenden Refreshs ('cli' oder 'bedrock').
|
||||
# Der Orchestrator setzt es je Lage (Auflösung Lage vor Organisation vor
|
||||
# globalem Default AI_BACKEND). None = globaler Default aus config.
|
||||
_ai_backend_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("_ai_backend_var", default=None)
|
||||
|
||||
logger = logging.getLogger("osint.claude_client")
|
||||
|
||||
|
||||
@@ -88,6 +93,17 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
|
||||
model: Optionales Modell (z.B. CLAUDE_MODEL_FAST fuer Haiku). None = CLAUDE_MODEL_STANDARD (Opus 4.7).
|
||||
timeout: Override in Sekunden. None = Fallback auf globalen CLAUDE_TIMEOUT (1800s).
|
||||
"""
|
||||
backend = _ai_backend_var.get(None) or AI_BACKEND
|
||||
if backend == "bedrock":
|
||||
if tools:
|
||||
# Phase 1 des EU-Umbaus. Bedrock hat keine eingebaute Websuche,
|
||||
# Aufrufe mit Tools laufen bis zur staan-Schleife (Phase 2)
|
||||
# weiter über das CLI.
|
||||
logger.info(f"Backend 'bedrock' aktiv, Aufruf braucht aber Tools ({tools}) und läuft übers CLI (Phase 1)")
|
||||
else:
|
||||
from agents.bedrock_client import call_bedrock
|
||||
return await call_bedrock(prompt, model=model, raw_text=raw_text, timeout=timeout)
|
||||
|
||||
effective_model = model or CLAUDE_MODEL_STANDARD
|
||||
effective_timeout = timeout if timeout is not None else CLAUDE_TIMEOUT
|
||||
cmd = [CLAUDE_PATH, "-p", "-", "--output-format", "json", "--model", effective_model]
|
||||
|
||||
737
src/agents/eu_researcher.py
Normale Datei
737
src/agents/eu_researcher.py
Normale Datei
@@ -0,0 +1,737 @@
|
||||
"""EU-Recherche-Schleife. Gesteuerte Websuche über Bedrock plus staan (Phase 2).
|
||||
|
||||
Ersetzt für den EU-Modellweg die eingebaute WebSearch-Recherche des Claude CLI.
|
||||
Das Modell (Opus über Bedrock, EU-Profile Frankfurt) schlägt je Runde
|
||||
Suchanfragen vor, unser Code fragt staan.ai, liefert Treffer und Volltexte
|
||||
zurück, und das Modell entscheidet, ob es nachhaken will oder genug hat.
|
||||
Am Ende produziert die Schleife exakt den JSON-Array-Text, den der heutige
|
||||
WebSearch-Researcher liefert, sodass Parsing, Quellenfilter und die gesamte
|
||||
nachgelagerte Pipeline unverändert weiterlaufen (Einstieg in
|
||||
researcher.ResearcherAgent.search).
|
||||
|
||||
Nachgebildet wird auch die vierstufige Tiefenrecherche der Recherche-Lagen
|
||||
(breite Erfassung, Lückenanalyse, gezielte institutionelle Suche, Vertiefung
|
||||
per Volltext). Zwei harte Zusagen dieser Schleife gegen Halluzinationen und
|
||||
für die Aktualität. Erstens akzeptiert die Endauswertung nur URLs, die
|
||||
wirklich in den staan-Treffern vorkamen (alles andere wird verworfen und
|
||||
geloggt). Zweitens wird published_at nur gesetzt, wenn das Datum aus Inhalt
|
||||
oder URL ableitbar ist, staan selbst liefert keine Datumsangaben.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from config import (
|
||||
CLAUDE_MODEL_STANDARD,
|
||||
EU_RESEARCH_MAX_NEW_PER_ROUND,
|
||||
EU_RESEARCH_MAX_ROUNDS_ADHOC,
|
||||
EU_RESEARCH_MAX_ROUNDS_RESEARCH,
|
||||
EU_VERIFY_COUNTER_SHARE,
|
||||
EU_VERIFY_FULLTEXT_CHARS,
|
||||
EU_VERIFY_FULLTEXT_QUERIES,
|
||||
EU_VERIFY_HITS_PER_QUERY,
|
||||
EU_VERIFY_MAX_ITEMS,
|
||||
EU_VERIFY_MAX_QUERIES,
|
||||
STAAN_COST_PER_QUERY_USD,
|
||||
TIMEZONE,
|
||||
)
|
||||
from agents.claude_client import ClaudeUsage, _cancel_event_var
|
||||
from agents.bedrock_client import call_bedrock
|
||||
from services.staan_client import staan_search, market_for_language, StaanError
|
||||
|
||||
logger = logging.getLogger("osint.eu_researcher")
|
||||
|
||||
MAX_QUERIES_PER_ROUND = 4
|
||||
MAX_FULLTEXT_QUERIES_PER_ROUND = 2
|
||||
MAX_TOTAL_RESULTS = 60
|
||||
# Kappung der Textmengen im Abschluss-Prompt (Kosten- und Kontextschutz)
|
||||
FINAL_FULLTEXT_CHARS = 2500
|
||||
FINAL_SNIPPET_CHARS = 400
|
||||
ROUND_SNIPPET_CHARS = 250
|
||||
|
||||
_ROUND_HEADER = """Du steuerst eine OSINT-Recherche über eine europäische Such-API.
|
||||
Du kannst NICHT selbst suchen. Du schlägst Suchanfragen vor, unser System führt sie aus
|
||||
und zeigt dir die Treffer. Es ist Runde {round_no} von maximal {max_rounds}.
|
||||
Heute ist der {today}. Formuliere Suchanfragen so, dass sie AKTUELLE Berichterstattung
|
||||
finden (konkrete Ereignisse, Akteure, Ortsnamen; keine Jahreszahlen anhängen).
|
||||
|
||||
AUFTRAG:
|
||||
Titel: {title}
|
||||
Kontext: {description}
|
||||
{existing_context}{preferred_sources_block}
|
||||
SPRACHREGELN:
|
||||
{lang_instruction}
|
||||
|
||||
{phase_guidance}
|
||||
|
||||
BISHER GESAMMELTE TREFFER ({n_results} Stück):
|
||||
{results_block}
|
||||
|
||||
VERLAUF DEINER BISHERIGEN SUCHEN:
|
||||
{rounds_log}
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt in genau diesem Format:
|
||||
{{"action": "search", "queries": [{{"q": "suchbegriffe", "market": "de-de", "full_content": false}}], "reason": "ein Satz"}}
|
||||
oder, wenn die Treffer für ein vollständiges Bild reichen:
|
||||
{{"action": "finish", "reason": "ein Satz"}}
|
||||
|
||||
REGELN FÜR QUERIES:
|
||||
- Maximal {max_queries} Queries je Runde, jede maximal 400 Zeichen.
|
||||
- "market" ist "de-de", "en-us" oder "fr-fr" (Standard für diese Lage: "{default_market}").
|
||||
Fremdsprachige Suchanfragen (z.B. Russisch, Arabisch, Farsi) funktionieren mit "en-us".
|
||||
- "full_content": true holt die kompletten Artikeltexte der Treffer dieser Query
|
||||
(maximal {max_fulltext} Queries je Runde). Nutze das für die wichtigsten Suchen,
|
||||
deren Artikel du zusammenfassen willst.
|
||||
- Wiederhole keine Query aus dem Verlauf wortgleich."""
|
||||
|
||||
_PHASE_GUIDANCE_RESEARCH = {
|
||||
1: """PHASE 1, BREITE ERFASSUNG:
|
||||
Suche nach aktueller Berichterstattung bei Nachrichtenagenturen, Qualitätszeitungen und
|
||||
öffentlich-rechtlichen Medien. Nutze verschiedene Suchbegriffe und Blickwinkel.""",
|
||||
2: """PHASE 2 UND 3, LÜCKENANALYSE UND GEZIELTE TIEFENRECHERCHE:
|
||||
Prüfe die bisherigen Treffer kritisch. Welche Quellentypen fehlen? Typisch fehlen
|
||||
Parlamentsdokumente, Behörden-Pressemitteilungen, NGO- und UN-Berichte (ohchr.org,
|
||||
amnesty.org, hrw.org), Think-Tank-Analysen (IISS, Brookings, SWP, DGAP, Chatham House),
|
||||
investigative Langform-Berichte und Fachmedien. Suche GEZIELT nach diesen Lücken,
|
||||
auch mit site:-Operatoren für institutionelle Quellen.""",
|
||||
3: """PHASE 4, VERIFIKATION UND VERTIEFUNG:
|
||||
Fordere für die wichtigsten Suchanfragen jetzt Volltexte an ("full_content": true),
|
||||
damit die Artikel ausführlich zusammengefasst werden können. Priorisiere Primärquellen
|
||||
und investigative Berichte. Ergänze nur noch gezielt, was für ein vollständiges Bild fehlt.""",
|
||||
}
|
||||
|
||||
_PHASE_GUIDANCE_ADHOC = {
|
||||
1: """SCHWERPUNKT DIESER RUNDE:
|
||||
Aktuelle Berichterstattung zur Lage bei seriösen Nachrichtenquellen (Agenturen,
|
||||
Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden). Verschiedene Blickwinkel.""",
|
||||
2: """SCHWERPUNKT DIESER RUNDE:
|
||||
Lücken schließen und vertiefen. Fordere für die wichtigsten Suchanfragen Volltexte an
|
||||
("full_content": true), damit die Artikel fundiert zusammengefasst werden können.""",
|
||||
3: """SCHWERPUNKT DIESER RUNDE, GEZIELTE VERTIEFUNG:
|
||||
Sieh die bisherigen Treffer durch und frage dich, welche Seite der Lage darin fehlt.
|
||||
Typische Lücken bei einem Ereignis mit politischer Wirkung: die Position des eigenen
|
||||
Landes und der Nachbarstaaten, die Reaktion der zuständigen Institutionen (EU-Kommission,
|
||||
Ministerien, Behörden), die Sicht der Gegenseite, der rechtliche Rahmen, die Ursachen und
|
||||
die Vorgeschichte. Suche gezielt nach dem, was fehlt, nicht noch einmal nach dem
|
||||
Hauptereignis. Fordere für diese Suchen Volltexte an ("full_content": true).""",
|
||||
}
|
||||
|
||||
_FINAL_PROMPT = """Du bist ein OSINT-Recherche-Agent. Die Recherche über die europäische Such-API ist
|
||||
abgeschlossen. Unten stehen ALLE gesammelten Treffer mit Textauszügen. Heute ist der {today}.
|
||||
|
||||
AUSGABESPRACHE: {output_language}
|
||||
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Sätze.
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.
|
||||
|
||||
AUFTRAG WAR:
|
||||
Titel: {title}
|
||||
Kontext: {description}
|
||||
|
||||
WÄHLE aus den Treffern die {target} relevantesten, inhaltlich substanziellen Artikel aus.
|
||||
Bevorzuge Vielfalt der Quellen und Blickwinkel. Lass Übersichtsseiten, reine Linklisten
|
||||
und thematisch unpassende Treffer weg.
|
||||
|
||||
GESAMMELTE TREFFER:
|
||||
{results_block}
|
||||
|
||||
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Text davor oder danach.
|
||||
Jedes Element hat diese Felder:
|
||||
- "headline": Originale Überschrift (aus dem Treffer)
|
||||
- "headline_de": Übersetzung in die Ausgabesprache (falls Originalsprache abweicht)
|
||||
- "source": Name der Quelle (z.B. "Reuters", "tagesschau")
|
||||
- "source_url": Die EXAKTE URL aus dem Treffer. NIEMALS eine URL verändern oder erfinden.
|
||||
- "content_summary": Zusammenfassung des Inhalts ({summary_len}, in Ausgabesprache, auf Basis der Textauszüge)
|
||||
- "language": Sprache des Originals (z.B. "de", "en", "fa")
|
||||
- "published_at": Veröffentlichungsdatum im ISO-Format, NUR wenn es aus Textauszug oder URL
|
||||
eindeutig hervorgeht (z.B. Datumsangabe im Text oder /2026/07/ im Pfad), sonst null.
|
||||
|
||||
Antworte NUR mit dem JSON-Array."""
|
||||
|
||||
|
||||
def _norm_url(u: str) -> str:
|
||||
return (u or "").strip().rstrip("/").lower()
|
||||
|
||||
|
||||
def _results_block(collected: dict[str, dict], with_texts: bool) -> str:
|
||||
"""Nummerierte Trefferliste für die Prompts. Kompakt in Zwischenrunden,
|
||||
mit Textauszügen im Abschluss-Prompt."""
|
||||
if not collected:
|
||||
return "(noch keine)"
|
||||
lines = []
|
||||
for i, (url, r) in enumerate(collected.items(), 1):
|
||||
lines.append(f"{i}. {r['title'] or '(ohne Titel)'} | {r['hostname']}\n URL: {url}")
|
||||
if with_texts:
|
||||
if r.get("snippet"):
|
||||
lines.append(f" Auszug: {r['snippet'][:FINAL_SNIPPET_CHARS]}")
|
||||
for chunk in (r.get("extra_snippets") or [])[:2]:
|
||||
lines.append(f" Auszug: {chunk[:FINAL_SNIPPET_CHARS]}")
|
||||
if r.get("full_text"):
|
||||
lines.append(f" Volltext (gekürzt): {r['full_text'][:FINAL_FULLTEXT_CHARS]}")
|
||||
else:
|
||||
if r.get("snippet"):
|
||||
lines.append(f" {r['snippet'][:ROUND_SNIPPET_CHARS]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _platz_schaffen(collected: dict[str, dict], runde: int) -> bool:
|
||||
"""Verdraengt den schwaechsten Treffer einer frueheren Runde. True bei Erfolg.
|
||||
|
||||
Frueher war die Trefferzahl eine harte Obergrenze: War sie erreicht, brach
|
||||
die Suchphase ab. In der Praxis fuellten die ersten beiden Runden den Korb
|
||||
mit breiter Erfassung, und die dritte Runde, die gezielt Luecken schliessen
|
||||
soll, lief nie. Genau diese Runde bringt aber die Treffer, die im Bericht
|
||||
fehlen.
|
||||
|
||||
Statt abzubrechen macht ein spaeterer, gezielterer Treffer jetzt Platz. Als
|
||||
schwaechster gilt ein Treffer ohne Volltext und ohne Textauszug aus der
|
||||
fruehesten Runde. Findet sich keiner, bleibt der Korb wie er ist.
|
||||
"""
|
||||
def guete(eintrag: dict) -> tuple:
|
||||
return (
|
||||
eintrag.get("_runde", 0),
|
||||
1 if eintrag.get("full_text") else 0,
|
||||
1 if eintrag.get("snippet") else 0,
|
||||
len(eintrag.get("extra_snippets") or []),
|
||||
)
|
||||
|
||||
kandidaten = [(k, v) for k, v in collected.items() if v.get("_runde", 0) < runde]
|
||||
if not kandidaten:
|
||||
return False
|
||||
schwaechster = min(kandidaten, key=lambda kv: guete(kv[1]))
|
||||
if schwaechster[1].get("full_text"):
|
||||
# Nur noch Treffer mit Volltext uebrig: die sind zu wertvoll zum
|
||||
# Verdraengen, der Korb bleibt wie er ist.
|
||||
return False
|
||||
del collected[schwaechster[0]]
|
||||
return True
|
||||
|
||||
|
||||
def _parse_action(text: str) -> dict:
|
||||
"""Aktions-JSON des Modells robust parsen. Fallback = finish."""
|
||||
from agents.researcher import _extract_json_object
|
||||
obj = None
|
||||
try:
|
||||
parsed = json.loads((text or "").strip())
|
||||
if isinstance(parsed, dict):
|
||||
obj = parsed
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
if obj is None:
|
||||
obj = _extract_json_object(text or "")
|
||||
if not isinstance(obj, dict) or obj.get("action") not in ("search", "finish"):
|
||||
logger.warning("EU-Recherche: Aktions-JSON nicht parsebar, beende Suchphase. Sample: %r", (text or "")[:200])
|
||||
return {"action": "finish", "queries": []}
|
||||
queries = []
|
||||
for q in obj.get("queries") or []:
|
||||
if isinstance(q, dict) and str(q.get("q", "")).strip():
|
||||
queries.append({
|
||||
"q": str(q["q"]).strip()[:400],
|
||||
"market": str(q.get("market", "")).strip().lower(),
|
||||
"full_content": bool(q.get("full_content")),
|
||||
})
|
||||
obj["queries"] = queries[:MAX_QUERIES_PER_ROUND]
|
||||
return obj
|
||||
|
||||
|
||||
async def run_eu_research(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
incident_type: str,
|
||||
lang_instruction: str,
|
||||
existing_context: str,
|
||||
preferred_sources_block: str,
|
||||
output_language: str,
|
||||
excluded_sources: list[str],
|
||||
research_language_iso: str,
|
||||
) -> tuple[str, ClaudeUsage]:
|
||||
"""Führt die komplette EU-Recherche aus. Gibt (json_array_text, usage) zurück.
|
||||
|
||||
Der Rückgabetext wird vom bestehenden ResearcherAgent._parse_response
|
||||
weiterverarbeitet, der Vertrag entspricht damit exakt dem CLI-Weg.
|
||||
"""
|
||||
total = ClaudeUsage()
|
||||
|
||||
def _add(u: ClaudeUsage):
|
||||
total.input_tokens += u.input_tokens
|
||||
total.output_tokens += u.output_tokens
|
||||
total.cache_creation_tokens += u.cache_creation_tokens
|
||||
total.cache_read_tokens += u.cache_read_tokens
|
||||
total.cost_usd += u.cost_usd
|
||||
total.duration_ms += u.duration_ms
|
||||
|
||||
is_research = incident_type == "research"
|
||||
max_rounds = EU_RESEARCH_MAX_ROUNDS_RESEARCH if is_research else EU_RESEARCH_MAX_ROUNDS_ADHOC
|
||||
guidance_map = _PHASE_GUIDANCE_RESEARCH if is_research else _PHASE_GUIDANCE_ADHOC
|
||||
today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")
|
||||
default_market = market_for_language(research_language_iso)
|
||||
# Nutzer-Ausschlüsse an staan weiterreichen (die Pflichtliste belegt die
|
||||
# ersten Plätze, siehe staan_client). Zusätzlich filtert der Aufrufer
|
||||
# (ResearcherAgent.search) das Endergebnis erneut gegen die volle Liste.
|
||||
extra_exclude = [d for d in (excluded_sources or []) if d and "." in d][:6]
|
||||
|
||||
collected: dict[str, dict] = {}
|
||||
rounds_log: list[str] = []
|
||||
searches_done = 0
|
||||
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
cancel = _cancel_event_var.get(None)
|
||||
if cancel and cancel.is_set():
|
||||
raise asyncio.CancelledError("Cancel angefordert")
|
||||
|
||||
guidance = guidance_map.get(min(round_no, max(guidance_map.keys())))
|
||||
prompt = _ROUND_HEADER.format(
|
||||
round_no=round_no,
|
||||
max_rounds=max_rounds,
|
||||
today=today,
|
||||
title=title,
|
||||
description=description or "Keine weitere Beschreibung",
|
||||
existing_context=existing_context or "",
|
||||
preferred_sources_block=preferred_sources_block or "",
|
||||
lang_instruction=lang_instruction,
|
||||
phase_guidance=guidance,
|
||||
n_results=len(collected),
|
||||
results_block=_results_block(collected, with_texts=False),
|
||||
rounds_log="\n".join(rounds_log) or "(noch keine)",
|
||||
max_queries=MAX_QUERIES_PER_ROUND,
|
||||
max_fulltext=MAX_FULLTEXT_QUERIES_PER_ROUND,
|
||||
default_market=default_market,
|
||||
)
|
||||
text, usage = await call_bedrock(prompt, model=CLAUDE_MODEL_STANDARD, timeout=420)
|
||||
_add(usage)
|
||||
|
||||
action = _parse_action(text)
|
||||
if action["action"] == "finish":
|
||||
if round_no == 1 and not collected:
|
||||
logger.warning("EU-Recherche: Modell wollte ohne eine einzige Suche beenden")
|
||||
else:
|
||||
logger.info("EU-Recherche: Suchphase nach Runde %d beendet (%s)", round_no - 1, action.get("reason", ""))
|
||||
break
|
||||
|
||||
fulltext_used = 0
|
||||
neu_in_runde = 0
|
||||
for q in action["queries"]:
|
||||
if neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
|
||||
logger.info(
|
||||
"EU-Recherche: Rundenkontingent %d erreicht, Rest der Runde uebersprungen",
|
||||
EU_RESEARCH_MAX_NEW_PER_ROUND,
|
||||
)
|
||||
break
|
||||
want_full = q["full_content"] and fulltext_used < MAX_FULLTEXT_QUERIES_PER_ROUND
|
||||
if want_full:
|
||||
fulltext_used += 1
|
||||
market = q["market"] if q["market"] else default_market
|
||||
try:
|
||||
results = await staan_search(
|
||||
q["q"], market=market, extra_snippets=True, max_snippets=4,
|
||||
full_content=want_full, extra_exclude=extra_exclude,
|
||||
)
|
||||
searches_done += 1
|
||||
except StaanError as e:
|
||||
logger.warning("EU-Recherche: Suche fehlgeschlagen (%s), überspringe Query", e)
|
||||
rounds_log.append(f"Runde {round_no}: '{q['q'][:70]}' -> FEHLER")
|
||||
continue
|
||||
new_count = 0
|
||||
for r in results:
|
||||
if not r["url"]:
|
||||
continue
|
||||
key = r["url"]
|
||||
if key in collected:
|
||||
# Volltext/Snippets aus späteren Suchen ergänzen
|
||||
if r.get("full_text") and not collected[key].get("full_text"):
|
||||
collected[key]["full_text"] = r["full_text"]
|
||||
continue
|
||||
if neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
|
||||
break
|
||||
if len(collected) >= MAX_TOTAL_RESULTS and not _platz_schaffen(collected, round_no):
|
||||
break
|
||||
r["_runde"] = round_no
|
||||
collected[key] = r
|
||||
new_count += 1
|
||||
neu_in_runde += 1
|
||||
rounds_log.append(
|
||||
f"Runde {round_no}: '{q['q'][:70]}' ({market}{', Volltexte' if want_full else ''}) -> {new_count} neue Treffer"
|
||||
)
|
||||
|
||||
if not action["queries"]:
|
||||
break
|
||||
|
||||
# Bringt eine ganze Runde keinen einzigen neuen Treffer, ist das Thema
|
||||
# ausgeschoepft. Weitere Runden wuerden nur das teuerste Modell
|
||||
# befragen, ohne dass danach etwas Neues dazukaeme.
|
||||
if neu_in_runde == 0:
|
||||
logger.info(
|
||||
"EU-Recherche: Suchphase nach Runde %d beendet, keine neuen Treffer mehr",
|
||||
round_no,
|
||||
)
|
||||
break
|
||||
|
||||
total.cost_usd += searches_done * STAAN_COST_PER_QUERY_USD
|
||||
|
||||
if not collected:
|
||||
logger.warning("EU-Recherche: keine Treffer gesammelt (%d Suchen)", searches_done)
|
||||
return "[]", total
|
||||
|
||||
target = "15 bis 25" if is_research else "8 bis 15"
|
||||
summary_len = "5 bis 8 Sätze" if is_research else "3 bis 5 Sätze"
|
||||
final_prompt = _FINAL_PROMPT.format(
|
||||
today=today,
|
||||
output_language=output_language,
|
||||
title=title,
|
||||
description=description or "Keine weitere Beschreibung",
|
||||
target=target,
|
||||
summary_len=summary_len,
|
||||
results_block=_results_block(collected, with_texts=True),
|
||||
)
|
||||
text, usage = await call_bedrock(final_prompt, model=CLAUDE_MODEL_STANDARD, timeout=600)
|
||||
_add(usage)
|
||||
|
||||
from agents.researcher import _extract_json_array
|
||||
arr = None
|
||||
try:
|
||||
parsed = json.loads((text or "").strip())
|
||||
if isinstance(parsed, list):
|
||||
arr = parsed
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
if arr is None:
|
||||
arr = _extract_json_array(text or "")
|
||||
if not isinstance(arr, list):
|
||||
# Unparsebarer Abschluss: Rohtext zurückgeben, damit der bestehende
|
||||
# Recovery-Pfad in _parse_response greift und die Usage verbucht wird.
|
||||
logger.warning("EU-Recherche: Abschluss-JSON nicht parsebar, reiche Rohtext weiter")
|
||||
return text or "[]", total
|
||||
|
||||
# Anti-Halluzination. Nur URLs akzeptieren, die wirklich in den staan-Treffern
|
||||
# vorkamen. published_at, das nicht wie ein Datum aussieht, wird verworfen.
|
||||
valid = {_norm_url(u) for u in collected}
|
||||
validated = []
|
||||
dropped = 0
|
||||
for item in arr:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if _norm_url(item.get("source_url", "")) not in valid:
|
||||
dropped += 1
|
||||
continue
|
||||
pub = item.get("published_at")
|
||||
if pub is not None and not isinstance(pub, str):
|
||||
item["published_at"] = None
|
||||
validated.append(item)
|
||||
if dropped:
|
||||
logger.warning("EU-Recherche: %d Artikel mit nicht belegten URLs verworfen", dropped)
|
||||
|
||||
logger.info(
|
||||
"EU-Recherche fertig: %d Artikel aus %d Treffern, %d Suchen, %d Modellrunden, $%.4f",
|
||||
len(validated), len(collected), searches_done, round_no + 1, total.cost_usd,
|
||||
)
|
||||
return json.dumps(validated, ensure_ascii=False), total
|
||||
|
||||
|
||||
# --- Phase 3: Stützsuche für Faktencheck und Lagebild --------------------------
|
||||
# Faktenchecker und Analyzer verlangen in ihren Prompts die eingebaute Websuche
|
||||
# (WebSearch/WebFetch), die es auf Bedrock nicht gibt. Im EU-Modus ersetzt
|
||||
# eu_call_with_search diese Aufrufe. Optional läuft vorab eine gezielte
|
||||
# staan-Stützsuche (Haiku plant Verifikations-Queries), deren Treffer als
|
||||
# Kontextblock in den Prompt wandern, danach geht der Aufruf werkzeuglos über
|
||||
# Bedrock. Der CLI-Modus bleibt in den Agenten unverändert.
|
||||
|
||||
_EU_TOOL_HINT = """
|
||||
|
||||
HINWEIS ZUR WEBSUCHE (EU-Modus):
|
||||
WebSearch und WebFetch stehen NICHT zur Verfügung. Ignoriere alle Anweisungen oben,
|
||||
diese Werkzeuge zu benutzen.{search_note} Behauptungen, die sich so nicht belegen
|
||||
lassen, bekommen den entsprechenden Unbestätigt-Status."""
|
||||
|
||||
_SEARCH_NOTE_WITH_RESULTS = (
|
||||
" Nutze stattdessen die unten angehängten ERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
|
||||
"als unabhängige Zweitquellen und zitiere sie als Evidenz, wenn sie eine "
|
||||
"Behauptung stützen oder widerlegen. Zusätzlich zählen die übergebenen Meldungen."
|
||||
" WICHTIG: Nenne bei jedem Beleg, auf den du dich stützt, immer die vollständige "
|
||||
"Adresse (die URL hinter der [S..]-Nummer), nicht nur die Nummer. Ohne Adresse "
|
||||
"gilt ein Beleg als nicht nachprüfbar."
|
||||
" EBENSO WICHTIG, so prüfst du einen Beleg: Ein Treffer, der nur das Thema "
|
||||
"erwähnt, ist KEIN Beleg. Die konkrete Aussage mit ihrer Zahl, ihrem Namen oder "
|
||||
"ihrem Datum muss im Titel, im Auszug oder im Artikeltext tatsächlich vorkommen. "
|
||||
"Steht dort nur, dass es die Lage gibt, oder ist die Angabe vager als deine "
|
||||
"Behauptung, dann trägt dieser Treffer sie nicht. Zähle ihn dann nicht mit und "
|
||||
"sage das in der Begründung ehrlich. Lieber ein unbestätigter Punkt mit klarer "
|
||||
"Begründung als eine Bestätigung, die einer Nachprüfung nicht standhält."
|
||||
)
|
||||
_SEARCH_NOTE_NO_RESULTS = (
|
||||
" Stütze dich ausschließlich auf die übergebenen Meldungen und Quellen."
|
||||
)
|
||||
|
||||
_VERIFY_QUERY_PROMPT = """Du planst Verifikations-Suchanfragen für einen OSINT-Faktencheck.
|
||||
Heute ist der {today}. Lage: {title}
|
||||
|
||||
ZU PRÜFENDE PUNKTE (nummeriert):
|
||||
{items_block}
|
||||
|
||||
AUFGABE:
|
||||
Erzeuge bis zu {max_queries} Websuche-Anfragen. Plane sie GEZIELT je Punkt, nicht
|
||||
pauschal für alle zusammen. Eine gute Anfrage macht genau einen dieser Punkte
|
||||
überprüfbar, mit seinen konkreten Zahlen, Namen und Orten.
|
||||
|
||||
Davon sind {counter_queries} Anfragen ausdrücklich GEGENPROBEN. Eine Gegenprobe sucht
|
||||
nicht nach Bestätigung, sondern nach allem, was der Behauptung widerspricht. Also nach
|
||||
Dementis, Richtigstellungen, abweichenden Zahlen anderer Stellen, nach der Darstellung
|
||||
der Gegenseite und nach Berichten, die den Vorgang anders einordnen. Wer nur nach
|
||||
Bestätigung sucht, findet auch nur Bestätigung, und der Faktencheck wird wertlos.
|
||||
Beispiele für Gegenproben: "Behörde dementiert Angaben zu X", "X Zahlen umstritten
|
||||
Kritik", "X widerspricht Darstellung". Markiere sie mit "gegenprobe": true.
|
||||
|
||||
REGELN:
|
||||
- Ordne jeder Anfrage über "for_items" zu, welche Punkte sie prüfen soll.
|
||||
- Decke möglichst viele verschiedene Punkte ab, statt denselben mehrfach zu suchen.
|
||||
- Priorisiere Punkte mit harten, überprüfbaren Angaben (Zahlen, Daten, Zitate, Beschlüsse).
|
||||
- Wähle für die Gegenproben die Punkte aus, bei denen ein Irrtum am schwersten wiegt,
|
||||
also Opferzahlen, Zuschreibungen von Verantwortung, Beschlüsse und Zitate.
|
||||
- Setze "full_content": true bei den Anfragen, deren Punkt sich nur im Artikeltext
|
||||
wirklich prüfen lässt, höchstens bei {fulltext_queries} Anfragen. Bei diesen bekommst
|
||||
du den Artikeltext statt nur der Überschrift.
|
||||
- Nutze die Sprache, in der die Berichterstattung zu erwarten ist. Für Ereignisse in
|
||||
einem Land sind Quellen in dessen Landessprache oft ergiebiger.
|
||||
- Keine Jahreszahlen anhängen, keine Wiederholung derselben Anfrage.
|
||||
{avoid_block}
|
||||
Antworte NUR mit JSON:
|
||||
{{"queries": [{{"q": "suchbegriffe", "market": "{default_market}", "for_items": [1, 2],
|
||||
"gegenprobe": false, "full_content": false}}]}}
|
||||
"market" ist "de-de", "en-us" oder "fr-fr". Fremdsprachige Anfragen funktionieren mit "en-us"."""
|
||||
|
||||
|
||||
def _iso_from_display(output_language: str) -> str:
|
||||
"""Anzeige-Sprachname der Agenten ('Deutsch') auf ISO für die Marktwahl."""
|
||||
mapping = {"deutsch": "de", "english": "en", "french": "fr"}
|
||||
return mapping.get((output_language or "").strip().lower(), "en")
|
||||
|
||||
|
||||
def _add_usage(total: ClaudeUsage, u: ClaudeUsage | None) -> None:
|
||||
"""Zaehlt einen Einzelverbrauch auf die Gesamtsumme."""
|
||||
if not u:
|
||||
return
|
||||
total.input_tokens += u.input_tokens
|
||||
total.output_tokens += u.output_tokens
|
||||
total.cache_creation_tokens += u.cache_creation_tokens
|
||||
total.cache_read_tokens += u.cache_read_tokens
|
||||
total.cost_usd += u.cost_usd
|
||||
total.duration_ms += u.duration_ms
|
||||
|
||||
|
||||
@dataclass
|
||||
class Belegsuche:
|
||||
"""Ergebnis einer gezielten Belegsuche.
|
||||
|
||||
block = fertiger Kontextblock fuer den Auftrag, leer wenn nichts gefunden
|
||||
usage = Verbrauch aus Planung und Suche
|
||||
queries = tatsaechlich ausgefuehrte Suchanfragen, fuer die Nachrunde
|
||||
quellen = gefundene Quellen einzeln, fuer die Quellenzuordnung im Faktencheck
|
||||
"""
|
||||
block: str
|
||||
usage: ClaudeUsage
|
||||
queries: list[str]
|
||||
quellen: list[dict]
|
||||
|
||||
|
||||
async def plan_verification_searches(
|
||||
*,
|
||||
title: str,
|
||||
search_items: list[str],
|
||||
output_language: str = "Deutsch",
|
||||
max_queries: int = EU_VERIFY_MAX_QUERIES,
|
||||
avoid_queries: list[str] | None = None,
|
||||
label: str = "Stützsuche",
|
||||
nur_gegenproben: bool = False,
|
||||
) -> Belegsuche:
|
||||
"""Plant gezielte Belegsuchen zu den uebergebenen Punkten und fuehrt sie aus.
|
||||
|
||||
Die Planung erfolgt punktbezogen, das planende Modell ordnet jeder Anfrage
|
||||
zu, welche Punkte sie belegen soll. avoid_queries verhindert, dass eine
|
||||
Nachrunde dieselben Anfragen noch einmal stellt.
|
||||
|
||||
Gibt eine Belegsuche zurueck. Der Kontextblock ist leer, wenn nichts
|
||||
gefunden wurde. Die gefundenen Quellen werden zusaetzlich einzeln
|
||||
zurueckgegeben, damit die Quellenzuordnung im Faktencheck sie kennt.
|
||||
"""
|
||||
from config import CLAUDE_MODEL_FAST
|
||||
|
||||
total = ClaudeUsage()
|
||||
items = [str(s).strip() for s in (search_items or []) if str(s).strip()][:EU_VERIFY_MAX_ITEMS]
|
||||
if not items:
|
||||
return Belegsuche("", total, [], [])
|
||||
|
||||
default_market = market_for_language(_iso_from_display(output_language))
|
||||
avoid_block = ""
|
||||
if avoid_queries:
|
||||
avoid_block = (
|
||||
"- Diese Anfragen wurden bereits gestellt und brachten nicht genug, stelle ANDERE:\n"
|
||||
+ "\n".join(f" {q[:120]}" for q in avoid_queries[:10]) + "\n"
|
||||
)
|
||||
|
||||
# Ein fester Anteil der Anfragen sucht nach Widerspruch statt nach
|
||||
# Bestaetigung. Ohne das sieht der Faktencheck nur stuetzendes Material und
|
||||
# bestaetigt praktisch alles.
|
||||
if nur_gegenproben:
|
||||
anzahl_gegenproben = max_queries
|
||||
else:
|
||||
anzahl_gegenproben = max(1, round(max_queries * EU_VERIFY_COUNTER_SHARE)) if max_queries >= 3 else 0
|
||||
anzahl_volltexte = min(EU_VERIFY_FULLTEXT_QUERIES, max_queries)
|
||||
|
||||
plan_prompt = _VERIFY_QUERY_PROMPT.format(
|
||||
today=datetime.now(TIMEZONE).strftime("%d.%m.%Y"),
|
||||
title=title or "(ohne Titel)",
|
||||
items_block="\n".join(f"{i}. {s[:220]}" for i, s in enumerate(items, 1)),
|
||||
max_queries=max_queries,
|
||||
counter_queries=anzahl_gegenproben,
|
||||
fulltext_queries=anzahl_volltexte,
|
||||
default_market=default_market,
|
||||
avoid_block=avoid_block,
|
||||
)
|
||||
|
||||
try:
|
||||
plan_text, plan_usage = await call_bedrock(plan_prompt, model=CLAUDE_MODEL_FAST, timeout=120)
|
||||
_add_usage(total, plan_usage)
|
||||
except Exception as e:
|
||||
logger.warning("EU-%s: Planung der Anfragen fehlgeschlagen (%s), fahre ohne Suche fort", label, e)
|
||||
return Belegsuche("", total, [], [])
|
||||
|
||||
from agents.researcher import _extract_json_object
|
||||
obj = _extract_json_object(plan_text or "")
|
||||
queries: list[dict] = []
|
||||
gesehen: set[str] = {(q or "").strip().lower() for q in (avoid_queries or [])}
|
||||
for q in (obj or {}).get("queries", []) or []:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
text = str(q.get("q", "")).strip()[:400]
|
||||
if not text or text.lower() in gesehen:
|
||||
continue
|
||||
gesehen.add(text.lower())
|
||||
queries.append({
|
||||
"q": text,
|
||||
"market": str(q.get("market", "")).strip().lower(),
|
||||
"gegenprobe": nur_gegenproben or bool(q.get("gegenprobe")),
|
||||
"full_content": bool(q.get("full_content")),
|
||||
})
|
||||
if len(queries) >= max_queries:
|
||||
break
|
||||
|
||||
if not queries:
|
||||
logger.warning("EU-%s: Planung lieferte keine brauchbaren Anfragen", label)
|
||||
return Belegsuche("", total, [], [])
|
||||
|
||||
lines: list[str] = []
|
||||
ausgefuehrt: list[str] = []
|
||||
quellen: list[dict] = []
|
||||
gesehene_urls: set[str] = set()
|
||||
treffer = 0
|
||||
volltexte_genutzt = 0
|
||||
gegenproben_gelaufen = 0
|
||||
for q in queries:
|
||||
will_volltext = q["full_content"] and volltexte_genutzt < anzahl_volltexte
|
||||
try:
|
||||
res = await staan_search(
|
||||
q["q"],
|
||||
market=q["market"] if q["market"] in ("de-de", "en-us", "fr-fr") else default_market,
|
||||
extra_snippets=True, max_snippets=3, full_content=will_volltext,
|
||||
)
|
||||
ausgefuehrt.append(q["q"])
|
||||
if will_volltext:
|
||||
volltexte_genutzt += 1
|
||||
if q["gegenprobe"]:
|
||||
gegenproben_gelaufen += 1
|
||||
except StaanError as e:
|
||||
logger.warning("EU-%s: Suche fehlgeschlagen (%s)", label, e)
|
||||
continue
|
||||
for r in res[:EU_VERIFY_HITS_PER_QUERY]:
|
||||
# Mehrere Anfragen finden oft dieselbe Seite, jede Quelle nur einmal
|
||||
# in den Kontextblock legen.
|
||||
url = (r.get("url") or "").strip()
|
||||
if url and url in gesehene_urls:
|
||||
continue
|
||||
if url:
|
||||
gesehene_urls.add(url)
|
||||
treffer += 1
|
||||
# Die Quelle wird zusaetzlich einzeln gefuehrt, damit die
|
||||
# Quellenzuordnung im Faktencheck sie wie eine Meldung behandeln
|
||||
# kann. Ohne das gelten Fakten als unbelegt, die sich allein auf
|
||||
# diese Belege stuetzen.
|
||||
quellen.append({
|
||||
"headline": r["title"] or "",
|
||||
"source": r["hostname"] or "",
|
||||
"source_url": url,
|
||||
})
|
||||
marke = " [GEGENPROBE]" if q["gegenprobe"] else ""
|
||||
lines.append(
|
||||
f"[S{treffer}]{marke} {r['title'] or '(ohne Titel)'} | {r['hostname']} | {r['url']}")
|
||||
if r.get("snippet"):
|
||||
lines.append(f" Auszug: {r['snippet'][:300]}")
|
||||
for chunk in (r.get("extra_snippets") or [])[:1]:
|
||||
lines.append(f" Auszug: {chunk[:300]}")
|
||||
# Volltext, damit das Modell pruefen kann, ob die Quelle die
|
||||
# Behauptung wirklich traegt, statt aus der Ueberschrift zu schliessen.
|
||||
volltext = (r.get("full_text") or "").strip()
|
||||
if volltext:
|
||||
lines.append(f" Artikeltext: {volltext[:EU_VERIFY_FULLTEXT_CHARS]}")
|
||||
|
||||
total.cost_usd += len(ausgefuehrt) * STAAN_COST_PER_QUERY_USD
|
||||
logger.info(
|
||||
"EU-%s: %d Suchen (davon %d Gegenproben, %d mit Artikeltext), %d Treffer im Kontextblock",
|
||||
label, len(ausgefuehrt), gegenproben_gelaufen, volltexte_genutzt, treffer)
|
||||
|
||||
if not lines:
|
||||
return Belegsuche("", total, ausgefuehrt, [])
|
||||
|
||||
kopf = (
|
||||
"\n\nERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
|
||||
"(von unserem System ausgeführt, als unabhängige Zweitquellen nutzbar).\n"
|
||||
)
|
||||
if gegenproben_gelaufen:
|
||||
kopf += (
|
||||
"Ein Teil dieser Suchen war eine GEGENPROBE, sie suchte gezielt nach "
|
||||
"Widerspruch, Dementi oder abweichenden Angaben. Diese Treffer sind mit "
|
||||
"[GEGENPROBE] gekennzeichnet. Findet sich dort nichts Widersprechendes, "
|
||||
"stützt das die Behauptung zusätzlich. Findet sich etwas, muss es in die "
|
||||
"Bewertung einfließen.\n"
|
||||
)
|
||||
block = kopf + "\n".join(lines)
|
||||
return Belegsuche(block, total, ausgefuehrt, quellen)
|
||||
|
||||
|
||||
def build_eu_prompt(prompt: str, results_block: str = "") -> str:
|
||||
"""Haengt den Werkzeug-Hinweis und einen etwaigen Belegblock an."""
|
||||
hint = _EU_TOOL_HINT.format(
|
||||
search_note=_SEARCH_NOTE_WITH_RESULTS if results_block else _SEARCH_NOTE_NO_RESULTS
|
||||
)
|
||||
return prompt + hint + results_block
|
||||
|
||||
|
||||
async def eu_call_with_search(
|
||||
prompt: str,
|
||||
*,
|
||||
title: str = "",
|
||||
search_items: list[str] | None = None,
|
||||
output_language: str = "Deutsch",
|
||||
max_queries: int = EU_VERIFY_MAX_QUERIES,
|
||||
) -> tuple[str, ClaudeUsage]:
|
||||
"""EU-Ersatz für call_claude mit Default-Tools (Faktencheck/Lagebild).
|
||||
|
||||
Mit search_items läuft vorab die gezielte staan-Belegsuche, ohne wird nur
|
||||
der Werkzeug-Hinweis angehängt. Der Modellaufruf geht werkzeuglos über
|
||||
call_claude und damit (aktives Bedrock-Backend vorausgesetzt) über
|
||||
Frankfurt. Gibt (result_text, gesamt_usage) zurück."""
|
||||
from agents.claude_client import call_claude
|
||||
|
||||
total = ClaudeUsage()
|
||||
results_block = ""
|
||||
if search_items:
|
||||
suche = await plan_verification_searches(
|
||||
title=title, search_items=search_items,
|
||||
output_language=output_language, max_queries=max_queries,
|
||||
)
|
||||
results_block = suche.block
|
||||
_add_usage(total, suche.usage)
|
||||
|
||||
result, usage = await call_claude(build_eu_prompt(prompt, results_block), tools=None)
|
||||
_add_usage(total, usage)
|
||||
return result, total
|
||||
@@ -29,19 +29,24 @@ STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
- Lieber "unconfirmed" als falsch bestätigt
|
||||
|
||||
AUFTRAG:
|
||||
1. Identifiziere die 5-10 wichtigsten Faktenaussagen aus den Meldungen
|
||||
1. Identifiziere die wichtigsten Faktenaussagen aus den Meldungen. Als Richtwert
|
||||
sind das etwa {fakten_min} bis {fakten_max} Aussagen. Das ist ein Richtwert und keine
|
||||
Zielvorgabe. Nenne weniger, wenn das Material nicht mehr hergibt, und mehr, wenn
|
||||
sonst wichtige Punkte der Lage fehlen wuerden.
|
||||
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
|
||||
- "confirmed": Mindestens 2 unabhängige Medienhäuser mit überprüfbarer URL bestätigen die Aussage
|
||||
- "unconfirmed": Nur ein Medienhaus, oder nicht unabhängig verifizierbar
|
||||
- "contradicted": Die Belege widersprechen EINANDER in der Sache, keine Seite ist entschieden
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "status": "confirmed" | "unconfirmed" | "contradicted" | "false" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Medienhäuser mit überprüfbarer URL
|
||||
- "evidence_type": "primary" | "secondary" | "agency" (siehe Bewertungsregeln)
|
||||
- "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)
|
||||
|
||||
@@ -69,19 +74,24 @@ STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
AUFTRAG:
|
||||
Fokus: "Was sind die gesicherten Fakten zu diesem Thema?"
|
||||
|
||||
1. Identifiziere die 5-10 wichtigsten Faktenaussagen aus den Quellen
|
||||
1. Identifiziere die wichtigsten Faktenaussagen aus den Quellen. Als Richtwert
|
||||
sind das etwa {fakten_min} bis {fakten_max} Aussagen. Das ist ein Richtwert und keine
|
||||
Zielvorgabe. Nenne weniger, wenn das Material nicht mehr hergibt, und mehr, wenn
|
||||
sonst wichtige Punkte der Lage fehlen wuerden.
|
||||
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
|
||||
- "established": Breit dokumentierter Fakt, 3+ unabhängige Medienhäuser mit URL
|
||||
- "disputed": Die Belege widersprechen EINANDER, verschiedene Positionen dokumentiert
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "status": "established" | "disputed" | "false" | "unverified" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Medienhäuser mit überprüfbarer URL
|
||||
- "evidence_type": "primary" | "secondary" | "agency" (siehe Bewertungsregeln)
|
||||
- "evidence": Begründung MIT konkreten Quellen-URLs als Beleg
|
||||
- "is_notification": true/false
|
||||
|
||||
@@ -111,21 +121,24 @@ STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
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
|
||||
3. Identifiziere NEUE Faktenaussagen aus den neuen Meldungen, als Richtwert etwa
|
||||
{fakten_min} bis {fakten_max}. Auch das ist ein Richtwert, keine Zielvorgabe.
|
||||
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
|
||||
- "confirmed": 2+ unabhängige Medienhäuser mit URL
|
||||
- "unconfirmed": Nur ein Medienhaus
|
||||
- "contradicted": Die Belege widersprechen einander
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "status": "confirmed" | "unconfirmed" | "contradicted" | "false" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Medienhäuser
|
||||
- "evidence_type": "primary" | "secondary" | "agency"
|
||||
- "evidence": Begründung MIT konkreten Quellen-URLs
|
||||
- "is_notification": true/false
|
||||
|
||||
@@ -153,20 +166,23 @@ STRENGE REGELN - KEINE HALLUZINATIONEN:
|
||||
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
|
||||
3. Identifiziere NEUE Faktenaussagen aus den neuen Quellen, als Richtwert etwa
|
||||
{fakten_min} bis {fakten_max}. Auch das ist ein Richtwert, keine Zielvorgabe.
|
||||
4. Prüfe neue Claims per WebSearch
|
||||
|
||||
Status-Kategorien:
|
||||
- "established": 3+ unabhängige Quellen mit URL
|
||||
- "disputed": Verschiedene Positionen dokumentiert
|
||||
- "established": 3+ unabhängige Medienhäuser mit URL
|
||||
- "disputed": Die Belege widersprechen einander, verschiedene Positionen dokumentiert
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "status": "established" | "disputed" | "false" | "unverified" | "developing"
|
||||
- "sources_count": Anzahl unabhängiger Medienhäuser
|
||||
- "evidence_type": "primary" | "secondary" | "agency"
|
||||
- "evidence": Begründung MIT konkreten Quellen-URLs
|
||||
- "is_notification": true/false
|
||||
|
||||
@@ -190,7 +206,8 @@ Analysiere die neuen Artikel und identifiziere:
|
||||
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)
|
||||
die noch nicht in den bestehenden Fakten erfasst sind? Als Richtwert etwa
|
||||
{fakten_min} bis {fakten_max} neue Claims, je nachdem was die Artikel hergeben.
|
||||
|
||||
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.
|
||||
@@ -237,14 +254,15 @@ 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
|
||||
- "confirmed": Mindestens 2 unabhängige Medienhäuser mit verifizierter URL bestätigen
|
||||
- "unconfirmed": Nicht genug unabhängige Bestätigung
|
||||
- "contradicted": Glaubwürdige Quellen widersprechen
|
||||
- "contradicted": Die Belege widersprechen einander in der Sache
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "confirmed" NUR bei mindestens 2 unabhängigen Medienhäusern 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
|
||||
@@ -254,8 +272,9 @@ Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||
{{{{
|
||||
"id": 123,
|
||||
"claim": "Die Faktenaussage...",
|
||||
"status": "confirmed|unconfirmed|contradicted|developing",
|
||||
"status": "confirmed|unconfirmed|contradicted|false|developing",
|
||||
"sources_count": 3,
|
||||
"evidence_type": "primary|secondary|agency",
|
||||
"evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
|
||||
"is_notification": false
|
||||
}}}}
|
||||
@@ -283,14 +302,15 @@ 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
|
||||
- "established": 3+ unabhängige Medienhäuser mit verifizierter URL
|
||||
- "unverified": Nicht unabhängig verifizierbar
|
||||
- "disputed": Verschiedene Positionen dokumentiert
|
||||
- "disputed": Die Belege widersprechen einander, verschiedene Positionen dokumentiert
|
||||
- "false": Ein Beleg widerlegt die Behauptung nachweislich
|
||||
- "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
|
||||
- "established" NUR bei mindestens 3 unabhängigen Medienhäusern mit ECHTER URL
|
||||
- KEINE Halluzinationen — nur tatsächlich per WebSearch gefundene Quellen
|
||||
- Bestehende Evidenz BEIBEHALTEN, nur neue ergänzen
|
||||
|
||||
@@ -299,8 +319,9 @@ Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||
{{{{
|
||||
"id": 123,
|
||||
"claim": "Die Faktenaussage...",
|
||||
"status": "established|unverified|disputed|developing",
|
||||
"status": "established|unverified|disputed|false|developing",
|
||||
"sources_count": 3,
|
||||
"evidence_type": "primary|secondary|agency",
|
||||
"evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
|
||||
"is_notification": false
|
||||
}}}}
|
||||
@@ -321,15 +342,255 @@ _STOPWORDS = frozenset({
|
||||
|
||||
STATUS_PRIORITY = {
|
||||
"confirmed": 5, "established": 5,
|
||||
"contradicted": 4, "disputed": 4,
|
||||
"contradicted": 4, "disputed": 4, "false": 4,
|
||||
"unconfirmed": 3, "unverified": 3,
|
||||
"developing": 1,
|
||||
}
|
||||
|
||||
# Status, die eine belegte Bestaetigung behaupten und deshalb an der Zahl
|
||||
# unabhaengiger Medienhaeuser gemessen werden.
|
||||
BESTAETIGT_STATUS = {"confirmed": 2, "established": 3}
|
||||
|
||||
# Ein Primaerbeleg (Originalzitat im Interview, amtliche Mitteilung, Urteil,
|
||||
# Pressemitteilung der handelnden Stelle) traegt allein. Mehrfache
|
||||
# Berichterstattung ueber denselben O-Ton ist keine zusaetzliche Verifikation,
|
||||
# und ihr Fehlen ist kein Mangel. Ohne diese Ausnahme wurde im Vergleich vom
|
||||
# 01.08.2026 ein ZDF-Interview als "unbestaetigt" gefuehrt, obwohl der
|
||||
# Betroffene die Aussage dort selbst getroffen hat.
|
||||
PRIMAERBELEG_MINDESTZAHL = 1
|
||||
|
||||
_BEWERTUNGSREGELN = """
|
||||
|
||||
BEWERTUNGSREGELN (verbindlich, sie gehen allen Angaben oben vor):
|
||||
- Unabhängig heißt: verschiedene Medienhäuser. Mehrere Artikel derselben
|
||||
Redaktion, Videofassungen derselben Meldung und wortgleiche Übernahmen
|
||||
derselben Agenturmeldung zählen zusammen als EINE Quelle.
|
||||
- Weiterleitungen über Sammelportale (news.google.com und ähnliche) sind kein
|
||||
eigenständiger Beleg. Zähle das Zielmedium, wenn es erkennbar ist.
|
||||
- "sources_count" ist die Zahl unabhängiger MEDIENHÄUSER, nicht die Zahl der
|
||||
Fundstellen oder Adressen.
|
||||
- "evidence_type" beschreibt den besten vorliegenden Beleg:
|
||||
"primary" = die handelnde oder sprechende Stelle selbst (Originalzitat im
|
||||
Interview, amtliche Mitteilung, Urteil, Pressemitteilung),
|
||||
"secondary" = unabhängige eigene Berichterstattung eines Mediums,
|
||||
"agency" = Agenturmeldung und ihre Übernahmen.
|
||||
Bei "primary" genügt diese eine Quelle für eine Bestätigung.
|
||||
- Unterschiedliche Zahlenangaben verschiedener Erhebungsstellen sind KEIN
|
||||
Widerspruch. Nenne die Spanne mit Zuordnung ("50.000 laut Innenministerium,
|
||||
60.000 laut Regionalregierung") und bewerte die Aussage nach ihrer
|
||||
Belegtiefe. Ein Widerspruchs-Status ist nur richtig, wenn die Belege
|
||||
einander in der Sache widersprechen.
|
||||
- Der Status für eine nachweisliche Widerlegung ist ausschließlich "false" und
|
||||
setzt einen Beleg voraus, der die Behauptung entkräftet. Eine fehlende
|
||||
Bestätigung ist keine Widerlegung.
|
||||
- Prüfe nur Tatsachenbehauptungen. Aussagen über den Kenntnisstand ("Der
|
||||
Auslöser ist unklar", "Eine unabhängige Bestätigung steht aus", "Die Lage
|
||||
bleibt unübersichtlich") sind nicht prüfbar und gehören NICHT in den
|
||||
Faktencheck. Formuliere stattdessen die prüfbare Tatsache dahinter oder lass
|
||||
den Punkt weg.
|
||||
- Die Behauptung nennt die Tatsache, nicht ihre Bewertung. Wertende Zusätze wie
|
||||
"freiwillig", "erfolgreich", "chaotisch", "rechtmäßig" gehören nur hinein,
|
||||
wenn genau dieser Zusatz belegt ist. Belegen die Quellen etwa eine Rückkehr,
|
||||
die nach behördlicher Aufforderung und unter Androhung von Abschiebung
|
||||
erfolgte, dann lautet die Behauptung "sind zurückgekehrt", nicht "sind
|
||||
freiwillig zurückgekehrt".
|
||||
- Korrigierte Werte sind eine Zeitreihe, keine Spanne. Wenn eine Zahl im Verlauf
|
||||
aktualisiert wurde, nenne den aktuellen Stand und die Korrektur ("mindestens
|
||||
67, zuvor wurden 57 gemeldet"), nicht beide Werte als Bandbreite ("57 bis
|
||||
67"). Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben
|
||||
Zeitpunkt verschiedene Werte erheben, dann mit Zuordnung.
|
||||
- Nimm bewusst auch strittige Punkte auf. Ein Faktencheck, in dem jede
|
||||
Behauptung bestätigt wird, sagt dem Leser nichts. Wähle unter den Aussagen
|
||||
auch solche aus, die nur eine Quelle haben, umstritten sind oder von den
|
||||
Beteiligten unterschiedlich dargestellt werden, und bewerte sie ehrlich. Wenn
|
||||
die Meldungen wirklich nichts Strittiges hergeben, ist das ein Ergebnis und
|
||||
keine Pflicht zur Erfindung."""
|
||||
|
||||
|
||||
def faktenspanne(anzahl_meldungen: int, neu: bool = False) -> tuple[int, int]:
|
||||
"""Richtwert fuer die Zahl der Faktenaussagen, gemessen am Material.
|
||||
|
||||
Frueher stand in allen Auftraegen fest "5-10 wichtigste Faktenaussagen".
|
||||
Das fuehrte dazu, dass aus 16 Meldungen genauso viele Fakten wurden wie aus
|
||||
91, obwohl die groessere Lage sichtbar mehr pruefbare Punkte hergab. Das
|
||||
Modell auf dem EU-Weg traf die Zehn in neun von siebzehn Laeufen exakt, es
|
||||
las die Spanne als Zielvorgabe. Der Weg ueber die Anthropic-CLI behandelte
|
||||
sie als Richtwert und lag zwischen neun und einundzwanzig.
|
||||
|
||||
neu=True gilt fuer die Aktualisierung, dort zaehlen nur die neu
|
||||
hinzugekommenen Meldungen und der Korridor ist enger.
|
||||
"""
|
||||
from config import (
|
||||
FAKTEN_JE_ARTIKEL, FAKTEN_MAX, FAKTEN_MIN,
|
||||
FAKTEN_NEU_JE_ARTIKEL, FAKTEN_NEU_MAX, FAKTEN_NEU_MIN,
|
||||
)
|
||||
|
||||
je_artikel = FAKTEN_NEU_JE_ARTIKEL if neu else FAKTEN_JE_ARTIKEL
|
||||
kleinstwert = FAKTEN_NEU_MIN if neu else FAKTEN_MIN
|
||||
groesstwert = FAKTEN_NEU_MAX if neu else FAKTEN_MAX
|
||||
|
||||
aus_material = round(max(0, anzahl_meldungen) / max(1, je_artikel))
|
||||
obergrenze = max(kleinstwert * 2, min(groesstwert, aus_material))
|
||||
untergrenze = max(kleinstwert, obergrenze // 2)
|
||||
return untergrenze, obergrenze
|
||||
|
||||
|
||||
def bewertungsregeln() -> str:
|
||||
"""Verbindlicher Regelblock, wird an jeden Faktencheck-Auftrag angehaengt.
|
||||
|
||||
Bewusst am Ende des Auftrags und an einer einzigen Stelle gepflegt, damit
|
||||
die sechs Vorlagen nicht auseinanderlaufen.
|
||||
"""
|
||||
return _BEWERTUNGSREGELN
|
||||
|
||||
# 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
|
||||
|
||||
# Status, die als "noch nicht ausreichend belegt" gelten und im EU-Modus eine
|
||||
# Nachhak-Runde ausloesen koennen.
|
||||
OFFENE_STATUS = ("unconfirmed", "unverified", "developing")
|
||||
|
||||
# Status, die eine Bestaetigung behaupten. Genau diese werden in der
|
||||
# Belastungsprobe noch einmal angegriffen.
|
||||
BESTAETIGTE_STATUS = ("confirmed", "established")
|
||||
|
||||
# Status, die einen Widerspruch oder eine Widerlegung ausdruecken. Sie duerfen
|
||||
# eine Bestaetigung auch zuruecknehmen, sonst gaebe es nur den Weg nach oben.
|
||||
WIDERSPRUCH_STATUS = ("contradicted", "disputed", "false")
|
||||
|
||||
EU_CHALLENGE_PROMPT_TEMPLATE = """Du bist ein OSINT-Faktenchecker und fuehrst eine BELASTUNGSPROBE durch.
|
||||
AUSGABESPRACHE: {output_language}
|
||||
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Saetze.
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.
|
||||
|
||||
LAGE: {title}
|
||||
|
||||
Im ersten Durchgang wurden die folgenden Behauptungen als belegt eingestuft. Das ist
|
||||
verdaechtig, denn bei einer laufenden Lage ist selten alles gesichert. Fuer genau diese
|
||||
Punkte wurden jetzt GEGENPROBEN gesucht, also gezielt nach Dementis, Richtigstellungen,
|
||||
abweichenden Angaben und der Darstellung der Gegenseite. Die Ergebnisse stehen unten.
|
||||
|
||||
ALS BELEGT EINGESTUFTE BEHAUPTUNGEN:
|
||||
{claims_text}
|
||||
|
||||
AUFGABE:
|
||||
Pruefe jede dieser Behauptungen kritisch gegen die unten angehaengten Suchergebnisse.
|
||||
Deine Aufgabe ist NICHT, die Bestaetigung zu wiederholen, sondern zu pruefen, ob sie
|
||||
haelt.
|
||||
|
||||
Stufe eine Behauptung zurueck, wenn einer dieser Punkte zutrifft:
|
||||
- Eine Quelle widerspricht ihr in der Sache, dann "{widerspruch}".
|
||||
- Eine Stelle hat sie ausdruecklich dementiert oder richtiggestellt, dann "false".
|
||||
- Die Belege nennen die konkrete Angabe gar nicht, sondern nur das Thema, dann "{offen}".
|
||||
- Die Angabe ist praeziser formuliert als die Quellen sie hergeben, etwa eine genaue
|
||||
Zahl, wo die Quellen von einer Schaetzung oder einer Spanne sprechen, dann "{offen}".
|
||||
- Nur eine einzige Stelle berichtet es und keine zweite bestaetigt es, dann "{offen}".
|
||||
|
||||
Lass sie unveraendert, wenn die Gegenprobe nichts Belastendes gebracht hat. Das ist ein
|
||||
gutes Ergebnis und keine Niederlage.
|
||||
|
||||
Erfinde keinen Zweifel. Ein Zurueckstufen braucht einen konkreten Beleg oder eine
|
||||
konkrete Luecke, die du benennen kannst.
|
||||
|
||||
Antworte NUR mit einem JSON-Array, ein Objekt je oben genannter Behauptung:
|
||||
[{{"claim": "Behauptung im Wortlaut wie oben", "status": "{status_werte}",
|
||||
"evidence": "Begruendung mit [S..] und vollstaendiger Adresse", "unveraendert": true}}]
|
||||
Setze "unveraendert" auf true, wenn die Behauptung haelt, sonst auf false."""
|
||||
|
||||
EU_FOLLOWUP_PROMPT_TEMPLATE = """Du bist ein OSINT-Faktenchecker.
|
||||
AUSGABESPRACHE: {output_language}
|
||||
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Saetze.
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.
|
||||
|
||||
LAGE: {title}
|
||||
|
||||
Die folgenden Behauptungen konnten im ersten Durchgang NICHT ausreichend belegt werden.
|
||||
Fuer genau diese Punkte wurden zusaetzliche Suchergebnisse beschafft, sie stehen unten.
|
||||
|
||||
OFFENE BEHAUPTUNGEN:
|
||||
{open_claims_text}
|
||||
|
||||
AUFGABE:
|
||||
Pruefe jede dieser Behauptungen erneut, ausschliesslich gegen die unten angehaengten
|
||||
Suchergebnisse.
|
||||
|
||||
REGELN:
|
||||
- Stufe eine Behauptung nur dann hoch, wenn die neuen Belege sie tatsaechlich stuetzen.
|
||||
- Widersprechen die Belege der Behauptung, setze den Status auf "contradicted".
|
||||
- Findest du keinen tragfaehigen Beleg, lass den Status unveraendert.
|
||||
- Nenne in "evidence" die stuetzende Quelle mit ihrer [S..]-Nummer UND ihrer vollstaendigen
|
||||
URL, damit der Beleg nachpruefbar bleibt, und fasse kurz zusammen, was sie belegt.
|
||||
- Erfinde nichts und uebernimm nichts aus deinem Gedaechtnis.
|
||||
|
||||
Antworte NUR mit einem JSON-Array, ein Objekt je oben genannter Behauptung:
|
||||
[{{"claim": "Behauptung im Wortlaut wie oben", "status": "{status_werte}", "evidence": "Begruendung mit [S..]"}}]"""
|
||||
|
||||
|
||||
_URL_RE = re.compile(r'https?://[^\s\)\]\},;"\'<>]+')
|
||||
|
||||
|
||||
def zaehle_medienhaeuser(urls: list[str]) -> int:
|
||||
"""Zaehlt unabhaengige Medienhaeuser statt Fundstellen.
|
||||
|
||||
Sechs France24-Treffer, davon vier Videofassungen derselben Meldung, sind
|
||||
ein Haus und kein sechsfacher Beleg. Gezaehlt wird deshalb nach
|
||||
registrierbarer Domain.
|
||||
|
||||
Weiterleitungen ueber Sammelportale zaehlen zusammen als EIN Beleg. Sie
|
||||
verbergen ihr Zielmedium, also laesst sich nicht behaupten, dass dahinter
|
||||
verschiedene Redaktionen stehen. Vier news.google.com-Adressen als vier
|
||||
unabhaengige Quellen zu zaehlen, hat im Vergleich vom 02.08.2026 den
|
||||
Anthropic-Weg rechnerisch bessergestellt: dort standen 10 Belege fuer
|
||||
tatsaechlich 7 Haeuser, davon vier Weiterleitungen.
|
||||
"""
|
||||
from services import media_registry
|
||||
|
||||
haeuser: set[str] = set()
|
||||
for url in urls or []:
|
||||
sauber = (url or "").rstrip(".,;:)]}\"'")
|
||||
if not sauber:
|
||||
continue
|
||||
domain = media_registry.registrable_domain(sauber)
|
||||
if not domain:
|
||||
continue
|
||||
haeuser.add("weiterleitung" if media_registry.ist_aggregator(sauber) else domain)
|
||||
return len(haeuser)
|
||||
|
||||
|
||||
def pruefe_belegtiefe(fact: dict) -> dict:
|
||||
"""Stuft einen Fakt ab, wenn sein Status mehr Beleg behauptet als vorliegt.
|
||||
|
||||
Ohne diese Nachbedingung stand im Bericht vom 01.08.2026 eine Behauptung
|
||||
als BESTAETIGT mit einer einzigen Quelle, deren Evidenz woertlich mit "Nur
|
||||
durch Al Jazeera berichtet" begann. Ursache war, dass die Nachhak-Runde den
|
||||
Status hochsetzte, ohne die Belegzahl neu zu bestimmen.
|
||||
|
||||
Ein Primaerbeleg (evidence_type 'primary') traegt allein, siehe
|
||||
PRIMAERBELEG_MINDESTZAHL.
|
||||
"""
|
||||
status = (fact.get("status") or "").strip().lower()
|
||||
noetig = BESTAETIGT_STATUS.get(status)
|
||||
if not noetig:
|
||||
return fact
|
||||
|
||||
if (fact.get("evidence_type") or "").strip().lower() == "primary":
|
||||
noetig = PRIMAERBELEG_MINDESTZAHL
|
||||
|
||||
vorhanden = fact.get("sources_count")
|
||||
if not isinstance(vorhanden, int):
|
||||
vorhanden = zaehle_medienhaeuser(_URL_RE.findall(fact.get("evidence") or ""))
|
||||
fact["sources_count"] = vorhanden
|
||||
|
||||
if vorhanden < noetig:
|
||||
neuer = "unconfirmed" if status == "confirmed" else "unverified"
|
||||
logger.warning(
|
||||
"Fakt herabgestuft (%s -> %s): %d unabhaengige Quelle(n), noetig waeren %d: '%s...'",
|
||||
status, neuer, vorhanden, noetig, (fact.get("claim") or "")[:60],
|
||||
)
|
||||
fact["status"] = neuer
|
||||
return fact
|
||||
|
||||
|
||||
def normalize_claim(claim: str) -> str:
|
||||
"""Normalisiert einen Claim fuer Aehnlichkeitsvergleich."""
|
||||
@@ -480,6 +741,349 @@ class FactCheckerAgent:
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _ist_eu_modus() -> bool:
|
||||
from agents.claude_client import _ai_backend_var
|
||||
from config import AI_BACKEND
|
||||
return (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock"
|
||||
|
||||
async def _eu_pruefen(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
title: str,
|
||||
articles: list[dict],
|
||||
output_language: str,
|
||||
search_items: list[str],
|
||||
incident_type: str = "adhoc",
|
||||
max_queries: int | None = None,
|
||||
followup_queries: int | None = None,
|
||||
) -> tuple[list[dict], ClaudeUsage]:
|
||||
"""Faktencheck im EU-Modus, mit gezielter Belegsuche und zweiter Runde.
|
||||
|
||||
Ablauf. Erstens werden zu den uebergebenen Punkten gezielt Belege
|
||||
gesucht und in den Auftrag gelegt, ein Teil davon als Gegenprobe.
|
||||
Zweitens laeuft der eigentliche Faktencheck. Drittens folgt eine zweite
|
||||
Runde, und zwar in beide Richtungen. Sind Behauptungen offen geblieben,
|
||||
wird fuer genau diese noch einmal gesucht und nachbelegt. Sieht dagegen
|
||||
alles bestaetigt aus, laeuft eine Belastungsprobe, die gezielt nach
|
||||
Widerspruch zu den staerksten Behauptungen sucht.
|
||||
|
||||
Der zweite Fall ist der wichtigere. Der EU-Weg bekommt seine Belege
|
||||
vorab beschafft und sieht deshalb nie, dass etwas fehlt. Ohne
|
||||
Belastungsprobe bestaetigte er acht Laeufe in Folge jede einzelne
|
||||
Behauptung, waehrend der Weg ueber die Anthropic-CLI bei derselben Lage
|
||||
rund ein Drittel offen liess.
|
||||
"""
|
||||
from agents.claude_client import call_claude
|
||||
from agents.eu_researcher import (
|
||||
plan_verification_searches, build_eu_prompt, _add_usage,
|
||||
)
|
||||
from config import (
|
||||
EU_VERIFY_MAX_QUERIES, EU_VERIFY_FOLLOWUP_QUERIES, EU_VERIFY_FOLLOWUP_MIN_OPEN,
|
||||
)
|
||||
|
||||
gesamt = ClaudeUsage()
|
||||
anzahl_anfragen = EU_VERIFY_MAX_QUERIES if max_queries is None else max_queries
|
||||
anzahl_nachhak = EU_VERIFY_FOLLOWUP_QUERIES if followup_queries is None else followup_queries
|
||||
|
||||
suche = await plan_verification_searches(
|
||||
title=title, search_items=search_items,
|
||||
output_language=output_language, max_queries=anzahl_anfragen,
|
||||
)
|
||||
_add_usage(gesamt, suche.usage)
|
||||
|
||||
result, usage = await call_claude(build_eu_prompt(prompt, suche.block), tools=None)
|
||||
_add_usage(gesamt, usage)
|
||||
# Die Suchbelege zaehlen bei der Quellenzuordnung wie Meldungen. Ohne
|
||||
# sie gelten Fakten als unbelegt, die sich allein auf die europaeische
|
||||
# Suche stuetzen, und werden faelschlich herabgestuft.
|
||||
facts = self._parse_response(result, articles=(articles or []) + suche.quellen)
|
||||
|
||||
if anzahl_nachhak <= 0:
|
||||
return facts, gesamt
|
||||
|
||||
# Die zweite Runde ist eine Verbesserung, keine Voraussetzung. Faellt sie
|
||||
# aus, bleibt das Ergebnis des ersten Durchgangs erhalten, statt dass der
|
||||
# ganze Faktencheck verloren geht.
|
||||
gestellte_anfragen = list(suche.queries)
|
||||
try:
|
||||
# Erster Schritt: offene Punkte nachbelegen, sofern welche da sind.
|
||||
offene = [f for f in facts if f.get("status") in OFFENE_STATUS]
|
||||
if len(offene) >= EU_VERIFY_FOLLOWUP_MIN_OPEN:
|
||||
logger.info(
|
||||
"EU-Faktencheck: %d von %d Behauptungen offen, starte Nachhak-Runde",
|
||||
len(offene), len(facts))
|
||||
facts, nachhak_usage, nachhak_anfragen = await self._eu_nachhaken(
|
||||
facts=facts, offene=offene, title=title,
|
||||
output_language=output_language, incident_type=incident_type,
|
||||
avoid_queries=gestellte_anfragen, followup_queries=anzahl_nachhak,
|
||||
)
|
||||
_add_usage(gesamt, nachhak_usage)
|
||||
gestellte_anfragen += nachhak_anfragen
|
||||
|
||||
# Zweiter Schritt: bleibt danach kaum etwas offen, wird das
|
||||
# geprueft. Ohne diesen Schritt hob die Nachhak-Runde am 02.08.2026
|
||||
# alle drei offenen Punkte wieder hoch und der Lauf endete erneut
|
||||
# ohne eine einzige Unsicherheit.
|
||||
offene = [f for f in facts if f.get("status") in OFFENE_STATUS]
|
||||
bestaetigt = [f for f in facts if f.get("status") in BESTAETIGTE_STATUS]
|
||||
if bestaetigt and len(offene) < EU_VERIFY_FOLLOWUP_MIN_OPEN:
|
||||
logger.info(
|
||||
"EU-Faktencheck: nur %d von %d Behauptungen offen, starte Belastungsprobe",
|
||||
len(offene), len(facts))
|
||||
facts, probe_usage = await self._eu_belastungsprobe(
|
||||
facts=facts, bestaetigt=bestaetigt, title=title,
|
||||
output_language=output_language, incident_type=incident_type,
|
||||
avoid_queries=gestellte_anfragen, queries=anzahl_nachhak,
|
||||
)
|
||||
_add_usage(gesamt, probe_usage)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"EU-Faktencheck: zweite Runde fehlgeschlagen (%s), Ergebnis des ersten "
|
||||
"Durchgangs bleibt bestehen", e)
|
||||
return facts, gesamt
|
||||
|
||||
async def _eu_belastungsprobe(
|
||||
self,
|
||||
*,
|
||||
facts: list[dict],
|
||||
bestaetigt: list[dict],
|
||||
title: str,
|
||||
output_language: str,
|
||||
incident_type: str,
|
||||
avoid_queries: list[str],
|
||||
queries: int,
|
||||
) -> tuple[list[dict], ClaudeUsage]:
|
||||
"""Greift die als belegt eingestuften Behauptungen gezielt an.
|
||||
|
||||
Sucht ausschliesslich nach Widerspruch und laesst das Modell die
|
||||
Bestaetigungen daran messen. Nur so kann der EU-Weg ueberhaupt zu einem
|
||||
unbestaetigten Ergebnis kommen, denn seine normale Belegsuche ist auf
|
||||
Bestaetigung ausgelegt und findet deshalb auch nur Bestaetigung.
|
||||
"""
|
||||
from agents.claude_client import call_claude
|
||||
from agents.eu_researcher import (
|
||||
plan_verification_searches, build_eu_prompt, _add_usage,
|
||||
)
|
||||
from json_utils import extract_json_arrays
|
||||
|
||||
gesamt = ClaudeUsage()
|
||||
kandidaten = bestaetigt[:MAX_FACTS_PER_VERIFY_GROUP]
|
||||
claims = [f.get("claim", "") for f in kandidaten if f.get("claim")]
|
||||
if not claims:
|
||||
return facts, gesamt
|
||||
|
||||
suche = await plan_verification_searches(
|
||||
title=title, search_items=claims, output_language=output_language,
|
||||
max_queries=queries, avoid_queries=avoid_queries,
|
||||
label="Belastungsprobe", nur_gegenproben=True,
|
||||
)
|
||||
_add_usage(gesamt, suche.usage)
|
||||
if not suche.block:
|
||||
logger.info("EU-Belastungsprobe: keine Gegenbelege gefunden, Bewertungen bleiben bestehen")
|
||||
return facts, gesamt
|
||||
|
||||
forschung = incident_type == "research"
|
||||
prompt = EU_CHALLENGE_PROMPT_TEMPLATE.format(
|
||||
output_language=output_language,
|
||||
title=title,
|
||||
claims_text="\n".join(f"- {c}" for c in claims),
|
||||
widerspruch="disputed" if forschung else "contradicted",
|
||||
offen="unverified" if forschung else "unconfirmed",
|
||||
status_werte=("established|unverified|disputed|false"
|
||||
if forschung else "confirmed|unconfirmed|contradicted|false"),
|
||||
) + bewertungsregeln()
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(build_eu_prompt(prompt, suche.block), tools=None)
|
||||
_add_usage(gesamt, usage)
|
||||
except TimeoutError:
|
||||
logger.warning("EU-Belastungsprobe: Timeout, Bewertungen bleiben bestehen")
|
||||
return facts, gesamt
|
||||
except Exception as e:
|
||||
logger.warning("EU-Belastungsprobe fehlgeschlagen (%s), Bewertungen bleiben bestehen", e)
|
||||
return facts, gesamt
|
||||
|
||||
fassungen = extract_json_arrays(result or "")
|
||||
if not fassungen:
|
||||
logger.warning("EU-Belastungsprobe: Antwort nicht auswertbar, Bewertungen bleiben bestehen")
|
||||
return facts, gesamt
|
||||
|
||||
zurueckgestuft = 0
|
||||
for eintrag in max(fassungen, key=len):
|
||||
if not isinstance(eintrag, dict):
|
||||
continue
|
||||
claim = str(eintrag.get("claim", "")).strip()
|
||||
neuer_status = str(eintrag.get("status", "")).strip().lower()
|
||||
if not claim or neuer_status not in STATUS_PRIORITY:
|
||||
continue
|
||||
treffer = find_matching_claim(claim, kandidaten)
|
||||
if not treffer:
|
||||
continue
|
||||
alt = treffer.get("status", "")
|
||||
if neuer_status == alt:
|
||||
continue
|
||||
# Die Belastungsprobe darf nur zurueckstufen. Eine Bestaetigung
|
||||
# laesst sich nicht dadurch erhaerten, dass die Gegenprobe nichts
|
||||
# gefunden hat.
|
||||
if neuer_status not in OFFENE_STATUS + WIDERSPRUCH_STATUS:
|
||||
continue
|
||||
treffer["status"] = neuer_status
|
||||
begruendung = str(eintrag.get("evidence", "")).strip()
|
||||
if begruendung:
|
||||
bisher = (treffer.get("evidence") or "").strip()
|
||||
treffer["evidence"] = (
|
||||
f"{bisher} Belastungsprobe: {begruendung}".strip() if bisher else begruendung)
|
||||
zurueckgestuft += 1
|
||||
logger.info(
|
||||
"EU-Belastungsprobe: '%s...' %s -> %s", claim[:60], alt, neuer_status)
|
||||
|
||||
logger.info(
|
||||
"EU-Belastungsprobe abgeschlossen: %d von %d Bestaetigungen zurueckgenommen",
|
||||
zurueckgestuft, len(kandidaten),
|
||||
)
|
||||
return facts, gesamt
|
||||
|
||||
async def _eu_nachhaken(
|
||||
self,
|
||||
*,
|
||||
facts: list[dict],
|
||||
offene: list[dict],
|
||||
title: str,
|
||||
output_language: str,
|
||||
incident_type: str,
|
||||
avoid_queries: list[str],
|
||||
followup_queries: int | None = None,
|
||||
) -> tuple[list[dict], ClaudeUsage, list[str]]:
|
||||
"""Sucht gezielt Belege fuer die offenen Behauptungen und bewertet nur
|
||||
diese erneut.
|
||||
|
||||
Gibt die zusammengefuehrte Faktenliste, den Verbrauch und die gestellten
|
||||
Anfragen zurueck. Letztere braucht die Belastungsprobe, damit sie nicht
|
||||
dieselben Anfragen noch einmal stellt.
|
||||
"""
|
||||
from agents.claude_client import call_claude
|
||||
from agents.eu_researcher import (
|
||||
plan_verification_searches, build_eu_prompt, _add_usage,
|
||||
)
|
||||
from config import EU_VERIFY_FOLLOWUP_QUERIES
|
||||
from json_utils import extract_json_arrays
|
||||
|
||||
gesamt = ClaudeUsage()
|
||||
kandidaten = offene[:MAX_FACTS_PER_VERIFY_GROUP]
|
||||
claims = [f.get("claim", "") for f in kandidaten if f.get("claim")]
|
||||
if not claims:
|
||||
return facts, gesamt, []
|
||||
|
||||
suche = await plan_verification_searches(
|
||||
title=title, search_items=claims, output_language=output_language,
|
||||
max_queries=(EU_VERIFY_FOLLOWUP_QUERIES if followup_queries is None else followup_queries),
|
||||
avoid_queries=avoid_queries, label="Nachhak-Suche",
|
||||
)
|
||||
block = suche.block
|
||||
_add_usage(gesamt, suche.usage)
|
||||
if not block:
|
||||
logger.info("EU-Nachhak-Runde: keine zusaetzlichen Belege gefunden, Fakten bleiben unveraendert")
|
||||
return facts, gesamt, suche.queries
|
||||
|
||||
status_werte = ("established|unverified|disputed|false|developing"
|
||||
if incident_type == "research"
|
||||
else "confirmed|unconfirmed|contradicted|false|developing")
|
||||
prompt = EU_FOLLOWUP_PROMPT_TEMPLATE.format(
|
||||
output_language=output_language,
|
||||
title=title,
|
||||
open_claims_text="\n".join(f"- {c}" for c in claims),
|
||||
status_werte=status_werte,
|
||||
) + bewertungsregeln()
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(build_eu_prompt(prompt, block), tools=None)
|
||||
_add_usage(gesamt, usage)
|
||||
except TimeoutError:
|
||||
logger.warning("EU-Nachhak-Runde: Timeout, Fakten bleiben unveraendert")
|
||||
return facts, gesamt, suche.queries
|
||||
except Exception as e:
|
||||
logger.warning("EU-Nachhak-Runde fehlgeschlagen (%s), Fakten bleiben unveraendert", e)
|
||||
return facts, gesamt, suche.queries
|
||||
|
||||
# Korrigiert sich das Modell selbst und haengt eine zweite Fassung an,
|
||||
# zaehlt die inhaltsreichste, nicht die erste.
|
||||
fassungen = extract_json_arrays(result or "")
|
||||
if not fassungen:
|
||||
logger.warning("EU-Nachhak-Runde: Antwort nicht auswertbar, Fakten bleiben unveraendert")
|
||||
return facts, gesamt, suche.queries
|
||||
aktualisierungen = max(fassungen, key=len)
|
||||
|
||||
hochgestuft = 0
|
||||
for eintrag in aktualisierungen:
|
||||
if not isinstance(eintrag, dict):
|
||||
continue
|
||||
claim = str(eintrag.get("claim", "")).strip()
|
||||
neuer_status = str(eintrag.get("status", "")).strip().lower()
|
||||
if not claim or neuer_status not in STATUS_PRIORITY:
|
||||
continue
|
||||
treffer = find_matching_claim(claim, kandidaten)
|
||||
if not treffer:
|
||||
continue
|
||||
alt = treffer.get("status", "developing")
|
||||
# Widerspricht die Nachrecherche der Behauptung, gilt das auch dann,
|
||||
# wenn der neue Status in der Rangfolge nicht hoeher steht. Ohne
|
||||
# diese Ausnahme kennt die Runde nur den Weg nach oben.
|
||||
if neuer_status in WIDERSPRUCH_STATUS and neuer_status != alt:
|
||||
treffer["status"] = neuer_status
|
||||
widerspruch = str(eintrag.get("evidence", "")).strip()
|
||||
if widerspruch:
|
||||
bisher = (treffer.get("evidence") or "").strip()
|
||||
treffer["evidence"] = (
|
||||
f"{bisher} Nachrecherche: {widerspruch}".strip() if bisher else widerspruch)
|
||||
logger.info(
|
||||
"EU-Nachhak-Runde: '%s...' %s -> %s, Beleg widerspricht",
|
||||
claim[:60], alt, neuer_status)
|
||||
continue
|
||||
if STATUS_PRIORITY.get(neuer_status, 0) <= STATUS_PRIORITY.get(alt, 0):
|
||||
continue
|
||||
# "developing" heisst: der Sachverhalt selbst ist noch offen. Das
|
||||
# ist keine schwache Beleglage, die sich nachrecherchieren laesst,
|
||||
# und wird deshalb nicht hochgestuft. Ohne diese Sperre wurde am
|
||||
# 01.08.2026 die Behauptung "Der Ausloeser ist weiterhin unklar"
|
||||
# auf BESTAETIGT gesetzt, weil mehrere Quellen die Unklarheit
|
||||
# beschrieben. Eine Unklarheit laesst sich nicht bestaetigen.
|
||||
if alt == "developing" and neuer_status not in ("contradicted", "disputed", "false"):
|
||||
logger.info(
|
||||
"EU-Nachhak-Runde: '%s...' bleibt developing, %s nicht zulaessig",
|
||||
claim[:60], neuer_status,
|
||||
)
|
||||
continue
|
||||
treffer["status"] = neuer_status
|
||||
belege = str(eintrag.get("evidence", "")).strip()
|
||||
if belege:
|
||||
bisher = (treffer.get("evidence") or "").strip()
|
||||
treffer["evidence"] = f"{bisher} Nachrecherche: {belege}".strip() if bisher else belege
|
||||
# Die Belegzahl folgt der neuen Evidenz, nicht dem alten Stand.
|
||||
treffer["sources_count"] = zaehle_medienhaeuser(
|
||||
_URL_RE.findall(treffer.get("evidence") or "")
|
||||
)
|
||||
if eintrag.get("evidence_type"):
|
||||
treffer["evidence_type"] = eintrag.get("evidence_type")
|
||||
pruefe_belegtiefe(treffer)
|
||||
if treffer["status"] != neuer_status:
|
||||
logger.info(
|
||||
"EU-Nachhak-Runde: '%s...' Hochstufung auf %s nicht gedeckt, bleibt %s",
|
||||
claim[:60], neuer_status, treffer["status"],
|
||||
)
|
||||
continue
|
||||
hochgestuft += 1
|
||||
logger.info("EU-Nachhak-Runde: '%s...' %s -> %s", claim[:60], alt, neuer_status)
|
||||
|
||||
logger.info(
|
||||
"EU-Nachhak-Runde abgeschlossen: %d von %d offenen Behauptungen belegt",
|
||||
hochgestuft, len(kandidaten),
|
||||
)
|
||||
return facts, gesamt, suche.queries
|
||||
|
||||
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:
|
||||
@@ -488,15 +1092,30 @@ class FactCheckerAgent:
|
||||
articles_text = self._format_articles_text(articles)
|
||||
|
||||
template = RESEARCH_FACTCHECK_PROMPT_TEMPLATE if incident_type == "research" else FACTCHECK_PROMPT_TEMPLATE
|
||||
fakten_min, fakten_max = faktenspanne(len(articles))
|
||||
logger.info(
|
||||
"Faktencheck: %d Meldungen, Richtwert %d bis %d Faktenaussagen",
|
||||
len(articles), fakten_min, fakten_max)
|
||||
prompt = template.format(
|
||||
title=title,
|
||||
articles_text=articles_text,
|
||||
output_language=output_language,
|
||||
)
|
||||
fakten_min=fakten_min,
|
||||
fakten_max=fakten_max,
|
||||
) + bewertungsregeln()
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
facts = self._parse_response(result, articles=articles)
|
||||
# EU-Modus: gezielte staan-Belegsuche plus Nachhak-Runde statt WebSearch.
|
||||
if self._ist_eu_modus():
|
||||
headlines = [a.get("headline_de") or a.get("headline", "") for a in articles[:10]]
|
||||
facts, usage = await self._eu_pruefen(
|
||||
prompt, title=title, articles=articles,
|
||||
output_language=output_language, incident_type=incident_type,
|
||||
search_items=[title] + headlines,
|
||||
)
|
||||
else:
|
||||
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:
|
||||
@@ -529,16 +1148,35 @@ class FactCheckerAgent:
|
||||
else:
|
||||
template = INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE
|
||||
|
||||
fakten_min, fakten_max = faktenspanne(len(new_articles), neu=True)
|
||||
logger.info(
|
||||
"Inkrementeller Faktencheck: %d neue Meldungen, Richtwert %d bis %d neue Aussagen",
|
||||
len(new_articles), fakten_min, fakten_max)
|
||||
prompt = template.format(
|
||||
title=title,
|
||||
articles_text=articles_text,
|
||||
existing_facts_text=existing_facts_text,
|
||||
output_language=output_language,
|
||||
)
|
||||
fakten_min=fakten_min,
|
||||
fakten_max=fakten_max,
|
||||
) + bewertungsregeln()
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
facts = self._parse_response(result, articles=new_articles)
|
||||
# EU-Modus: gezielte Belegsuche zu offenen Fakten plus Nachhak-Runde.
|
||||
if self._ist_eu_modus():
|
||||
open_claims = [
|
||||
f.get("claim", "") for f in existing_facts
|
||||
if f.get("status") in ("unconfirmed", "developing", "disputed", "unverified")
|
||||
][:8]
|
||||
headlines = [a.get("headline_de") or a.get("headline", "") for a in new_articles[:6]]
|
||||
facts, usage = await self._eu_pruefen(
|
||||
prompt, title=title, articles=new_articles,
|
||||
output_language=output_language, incident_type=incident_type,
|
||||
search_items=open_claims + headlines,
|
||||
)
|
||||
else:
|
||||
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:
|
||||
@@ -575,6 +1213,7 @@ class FactCheckerAgent:
|
||||
articles_text = self._format_articles_text(new_articles, max_articles=15)
|
||||
|
||||
from config import CLAUDE_MODEL_FAST
|
||||
triage_min, triage_max = faktenspanne(len(new_articles), neu=True)
|
||||
triage_prompt = TRIAGE_PROMPT_TEMPLATE.format(
|
||||
output_language=output_language,
|
||||
fact_count=len(existing_facts),
|
||||
@@ -582,6 +1221,8 @@ class FactCheckerAgent:
|
||||
article_count=len(new_articles),
|
||||
articles_text=articles_text,
|
||||
max_per_group=MAX_FACTS_PER_VERIFY_GROUP,
|
||||
fakten_min=triage_min,
|
||||
fakten_max=triage_max,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -642,11 +1283,27 @@ class FactCheckerAgent:
|
||||
facts_text=facts_text,
|
||||
new_claims_text=new_claims_text,
|
||||
articles_text=articles_text,
|
||||
)
|
||||
) + bewertungsregeln()
|
||||
|
||||
try:
|
||||
result, v_usage = await call_claude(prompt)
|
||||
facts = self._parse_response(result)
|
||||
# EU-Modus: gezielte Belegsuche je Faktengruppe plus Nachhak-Runde.
|
||||
if self._ist_eu_modus():
|
||||
from config import (
|
||||
EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
|
||||
)
|
||||
claims = [f.get("claim", "") for f in group_facts] + list(group_claims)
|
||||
# Kleinere Suchmenge je Gruppe, da mehrere Gruppen parallel
|
||||
# laufen und jede nur ein Teilthema abdeckt.
|
||||
facts, v_usage = await self._eu_pruefen(
|
||||
prompt, title=f"{title} ({theme})", articles=None,
|
||||
output_language=output_language, incident_type=incident_type,
|
||||
search_items=claims[:8],
|
||||
max_queries=EU_VERIFY_GROUP_QUERIES,
|
||||
followup_queries=EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
|
||||
)
|
||||
else:
|
||||
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:
|
||||
@@ -737,9 +1394,11 @@ class FactCheckerAgent:
|
||||
"""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.
|
||||
evidence hat, damit das Frontend die Quellen korrekt anzeigen kann,
|
||||
zaehlt die Belege nach unabhaengigen Medienhaeusern und stuft Fakten
|
||||
ab, deren Status mehr Belegtiefe behauptet als vorhanden ist.
|
||||
"""
|
||||
url_pattern = re.compile(r'https?://')
|
||||
url_pattern = _URL_RE
|
||||
# Verfuegbare Artikel-URLs sammeln (dedupliziert nach URL)
|
||||
article_sources = []
|
||||
seen_urls = set()
|
||||
@@ -797,8 +1456,9 @@ class FactCheckerAgent:
|
||||
)
|
||||
|
||||
# sources_count aktualisieren
|
||||
all_urls = url_pattern.findall(fact["evidence"])
|
||||
fact["sources_count"] = len(set(all_urls))
|
||||
fact["sources_count"] = zaehle_medienhaeuser(
|
||||
url_pattern.findall(fact["evidence"])
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fakt '{fact.get('claim', '')[:50]}...' ergaenzt mit "
|
||||
@@ -812,6 +1472,15 @@ class FactCheckerAgent:
|
||||
f"Fakt herabgestuft ({old_status} -> {fact['status']}): "
|
||||
f"keine Quellen zuordnebar: '{fact.get('claim', '')[:60]}...'"
|
||||
)
|
||||
else:
|
||||
# Nur Belege aus der Antwort des Modells: die Zahl trotzdem
|
||||
# selbst bestimmen, statt der Selbstauskunft zu vertrauen.
|
||||
fact["sources_count"] = zaehle_medienhaeuser(
|
||||
url_pattern.findall(fact["evidence"] or "")
|
||||
)
|
||||
|
||||
for fact in facts:
|
||||
pruefe_belegtiefe(fact)
|
||||
return facts
|
||||
|
||||
def _parse_response(self, response: str, articles: list[dict] = None) -> list[dict]:
|
||||
@@ -832,5 +1501,25 @@ class FactCheckerAgent:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning("Konnte Faktencheck-Antwort nicht als JSON parsen")
|
||||
# Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche Zitate
|
||||
# in claim/evidence). Ohne diesen Schritt lieferte der Faktencheck bei
|
||||
# einem einzigen Stoerzeichen still eine leere Liste.
|
||||
# Liefert das Modell mehrere Fassungen, weil es sich selbst korrigiert
|
||||
# hat, zaehlt die mit den meisten Fakten.
|
||||
from json_utils import extract_json_arrays as _forgiving_arrays
|
||||
fassungen = _forgiving_arrays(response)
|
||||
if fassungen:
|
||||
beste = max(fassungen, key=len)
|
||||
if len(fassungen) > 1:
|
||||
logger.warning(
|
||||
"Faktencheck-Antwort enthielt %d Fassungen, nehme die mit den "
|
||||
"meisten Fakten (%d statt %d)",
|
||||
len(fassungen), len(beste), len(fassungen[0]),
|
||||
)
|
||||
return self._validate_facts(beste, articles=articles)
|
||||
|
||||
logger.warning(
|
||||
"Konnte Faktencheck-Antwort nicht als JSON parsen (Laenge: %d, Anfang: %r)",
|
||||
len(response or ""), (response or "")[:200],
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -11,7 +11,8 @@ from urllib.parse import urlparse, urlunparse, quote_plus
|
||||
|
||||
import httpx
|
||||
|
||||
from agents.claude_client import UsageAccumulator, _cancel_event_var
|
||||
from agents.claude_client import UsageAccumulator, _cancel_event_var, _ai_backend_var
|
||||
from agents import bedrock_client
|
||||
from agents.factchecker import find_matching_claim, deduplicate_new_facts, TWOPHASE_MIN_FACTS
|
||||
from source_rules import (
|
||||
_detect_category,
|
||||
@@ -470,7 +471,12 @@ class AgentOrchestrator:
|
||||
return False
|
||||
|
||||
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
|
||||
lane_key = tenant_id if tenant_id else self.PUBLIC_LANE
|
||||
# Doppelspur (EU-Umbau Phase 2). Eine Lane je Organisation UND KI-Weg.
|
||||
# Ein CLI-Lauf und ein EU-Lauf derselben Organisation laufen dadurch
|
||||
# wirklich gleichzeitig (getrennte Ressourcen, Abo gegen AWS), während
|
||||
# Läufe über denselben Weg seriell bleiben (Rate-Limit-Schutz).
|
||||
ai_backend = await self._resolve_ai_backend(incident_id)
|
||||
lane_key = (tenant_id if tenant_id else self.PUBLIC_LANE, ai_backend)
|
||||
|
||||
async with self._lanes_lock:
|
||||
queue = self._lanes.get(lane_key)
|
||||
@@ -611,6 +617,13 @@ class AgentOrchestrator:
|
||||
def _is_transient_cli(err: Exception) -> bool:
|
||||
return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout")
|
||||
|
||||
def _is_db_locked(err: Exception) -> bool:
|
||||
# Seit der Doppelspur (Phase 2) koennen sich parallele Lanes beim
|
||||
# Schreiben auf die SQLite-DB kurz blockieren. Das ist transient
|
||||
# und einen Retry wert, kein permanenter Fehler.
|
||||
import sqlite3 as _sqlite3
|
||||
return isinstance(err, _sqlite3.OperationalError) and "locked" in str(err).lower()
|
||||
|
||||
# Optionales globales Ventil (Default aus): begrenzt gleichzeitige Recherchen.
|
||||
sem = self._global_sem
|
||||
if sem is not None:
|
||||
@@ -652,7 +665,7 @@ class AgentOrchestrator:
|
||||
break
|
||||
|
||||
# Transiente Fehler: Retry bis 3x
|
||||
if isinstance(e, TRANSIENT_ERRORS) or _is_transient_cli(e):
|
||||
if isinstance(e, TRANSIENT_ERRORS) or _is_transient_cli(e) or _is_db_locked(e):
|
||||
last_error = e
|
||||
kind = e.error_type if isinstance(e, ClaudeCliError) else type(e).__name__
|
||||
logger.warning(f"Transienter Fehler [{kind}] bei Lage {incident_id} (Versuch {attempt + 1}/3): {e}")
|
||||
@@ -699,6 +712,7 @@ class AgentOrchestrator:
|
||||
self._cancel_events.pop(incident_id, None)
|
||||
self._cancel_requested.discard(incident_id)
|
||||
_cancel_event_var.set(None)
|
||||
_ai_backend_var.set(None)
|
||||
queue.task_done()
|
||||
|
||||
async def _mark_refresh_cancelled(self, incident_id: int):
|
||||
@@ -791,6 +805,35 @@ class AgentOrchestrator:
|
||||
await db.close()
|
||||
return visibility, created_by, tenant_id
|
||||
|
||||
async def _resolve_ai_backend(self, incident_id: int) -> str:
|
||||
"""Löst das KI-Backend einer Lage auf (EU-Umbau).
|
||||
|
||||
Lage (incidents.ai_backend) gewinnt vor dem Org-Setting 'ai_backend',
|
||||
das vor dem globalen Default AI_BACKEND aus config. Wird beim Einreihen
|
||||
(Lane-Wahl) und beim Refresh-Start (ContextVar) identisch genutzt."""
|
||||
from config import AI_BACKEND
|
||||
from database import get_db
|
||||
from services.org_settings import get_org_setting
|
||||
incident_backend = None
|
||||
tenant_id = None
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT ai_backend, tenant_id FROM incidents WHERE id = ?", (incident_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
incident_backend = row["ai_backend"] if "ai_backend" in row.keys() else None
|
||||
tenant_id = row["tenant_id"] if "tenant_id" in row.keys() else None
|
||||
org_backend = await get_org_setting(db, tenant_id, "ai_backend", default=None) if tenant_id else None
|
||||
finally:
|
||||
await db.close()
|
||||
ai_backend = (incident_backend or org_backend or AI_BACKEND or "cli").strip().lower()
|
||||
if ai_backend not in ("cli", "bedrock"):
|
||||
logger.warning("Unbekanntes ai_backend '%s' für Lage %s, fallback 'cli'", ai_backend, incident_id)
|
||||
ai_backend = "cli"
|
||||
return ai_backend
|
||||
|
||||
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None, _suppress_complete: bool = False, _pass_info: dict = None, collect_only: bool = False):
|
||||
"""Führt einen kompletten Refresh-Zyklus durch."""
|
||||
import aiosqlite
|
||||
@@ -829,6 +872,31 @@ class AgentOrchestrator:
|
||||
get_org_language, language_display, get_research_language,
|
||||
get_source_language_whitelist, get_translator_enabled,
|
||||
)
|
||||
# KI-Backend für diesen Refresh auflösen und per ContextVar setzen
|
||||
# (EU-Umbau, gemeinsame Auflösung mit der Lane-Wahl beim Einreihen,
|
||||
# siehe _resolve_ai_backend).
|
||||
ai_backend = await self._resolve_ai_backend(incident_id)
|
||||
_ai_backend_var.set(ai_backend)
|
||||
if ai_backend != "cli":
|
||||
logger.info("Lage %s läuft über KI-Backend '%s'", incident_id, ai_backend)
|
||||
# Wartebudget des EU-Wegs zuruecksetzen und die Oberflaeche an die
|
||||
# Wartemeldungen anschliessen. Ohne diesen Hinweis sieht eine
|
||||
# Wiederholung nach einem Kapazitaetsengpass wie ein Haenger aus,
|
||||
# weil die Anzeige waehrend der Wartezeit stillsteht.
|
||||
bedrock_client.refresh_kontext_starten()
|
||||
if self._ws_manager:
|
||||
def _warte_melden(art: str, sekunden: float, _iid=incident_id,
|
||||
_vis=visibility, _cb=created_by, _tid=tenant_id) -> None:
|
||||
text = bedrock_client._ART_TEXT.get(art, art)
|
||||
asyncio.create_task(self._ws_manager.broadcast_for_incident({
|
||||
"type": "status_update",
|
||||
"incident_id": _iid,
|
||||
"data": {
|
||||
"status": "running",
|
||||
"detail": f"KI-Dienst {text}, neuer Versuch in {sekunden:.0f} Sekunden...",
|
||||
},
|
||||
}, _vis, _cb, _tid))
|
||||
bedrock_client.wartemelder_setzen(_warte_melden)
|
||||
output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
||||
output_language = language_display(output_language_iso)
|
||||
# research_language steuert nur den WebSearch-Prompt ("suche in Sprache X").
|
||||
@@ -2024,7 +2092,7 @@ class AgentOrchestrator:
|
||||
# Status-Statistik sammeln
|
||||
if new_status in ("confirmed", "established"):
|
||||
confirmed_count += 1
|
||||
elif new_status in ("contradicted", "disputed"):
|
||||
elif new_status in ("contradicted", "disputed", "false"):
|
||||
contradicted_count += 1
|
||||
|
||||
await db.commit()
|
||||
@@ -2112,7 +2180,7 @@ class AgentOrchestrator:
|
||||
"type": "status_change",
|
||||
"title": title,
|
||||
"text": f"{sc['claim']}: {sc['old_status']} \u2192 {sc['new_status']}",
|
||||
"icon": "error" if sc["new_status"] in ("contradicted", "disputed") else "success",
|
||||
"icon": "error" if sc["new_status"] in ("contradicted", "disputed", "false") else "success",
|
||||
})
|
||||
|
||||
if created_by:
|
||||
@@ -2151,18 +2219,24 @@ class AgentOrchestrator:
|
||||
if _missing_key not in _started_keys:
|
||||
await _pipe_skip(_missing_key)
|
||||
|
||||
# Refresh-Log abschließen (mit Token-Statistiken)
|
||||
# Refresh-Log abschließen (mit Token-Statistiken). Störungen des
|
||||
# KI-Dienstes werden auch bei einem erfolgreichen Lauf vermerkt,
|
||||
# sonst sieht ein Durchlauf mit Aussetzern aus wie ein glatter.
|
||||
stoerungshinweis = bedrock_client.stoerungen_zusammenfassen() or None
|
||||
if stoerungshinweis:
|
||||
logger.info("Refresh-Protokoll Lage %s: %s", incident_id, stoerungshinweis)
|
||||
await db.execute(
|
||||
"""UPDATE refresh_log SET
|
||||
completed_at = ?, articles_found = ?, status = 'completed',
|
||||
input_tokens = ?, output_tokens = ?,
|
||||
cache_creation_tokens = ?, cache_read_tokens = ?,
|
||||
total_cost_usd = ?, api_calls = ?
|
||||
total_cost_usd = ?, api_calls = ?, error_message = ?
|
||||
WHERE id = ?""",
|
||||
(datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), new_count,
|
||||
usage_acc.input_tokens, usage_acc.output_tokens,
|
||||
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
|
||||
round(usage_acc.total_cost_usd, 7), usage_acc.call_count, log_id),
|
||||
round(usage_acc.total_cost_usd, 7), usage_acc.call_count,
|
||||
stoerungshinweis, log_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -413,6 +413,15 @@ KEYWORDS-REGELN:
|
||||
- Wenn die Lage rein deutsch oder englisch ist und keine fremdsprachigen Feeds gewählt werden,
|
||||
reichen "de" und/oder "en".
|
||||
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
|
||||
- EINZELWÖRTER, keine Phrasen. "ceuta" und "migranten" statt "migrationskrise spanien".
|
||||
Die Begriffe werden gegen Schlagzeilen geprüft, und dort steht selten die exakte
|
||||
Wortfolge einer Phrase. Nur wo ein Begriff untrennbar ist (Eigenname wie
|
||||
"nord stream"), darf er aus zwei Wörtern bestehen.
|
||||
- Die HARTEN Eigennamen der Lage MÜSSEN dabei sein: Schauplatz (Ort, Region), beteiligte
|
||||
Länder, handelnde Personen, betroffene Organisationen. Eine Lage über Ceuta ohne das
|
||||
Wort "ceuta" ist unbrauchbar. Diese Namen zuerst nennen, danach die Sachbegriffe.
|
||||
- KEINE abstrakten Politikbegriffe als Suchwort ("aufnahmeverfahren", "grenzschutz europa").
|
||||
Sie stehen fast nie in einer Schlagzeile und ziehen dafür themenfremde Behördenfeeds an.
|
||||
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
|
||||
- KEINE Monatsnamen (Januar, Februar, März etc.)
|
||||
- KEINE generischen Wörter (aktuell, news, update etc.)
|
||||
@@ -804,7 +813,28 @@ class ResearcherAgent:
|
||||
)
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt)
|
||||
# EU-Modellweg (Phase 2). Läuft der Refresh über das Bedrock-Backend,
|
||||
# übernimmt die gesteuerte staan-Schleife die komplette Recherche und
|
||||
# liefert denselben JSON-Array-Text wie die CLI-WebSearch. Parsing,
|
||||
# Quellenfilter und Sprachfilter darunter bleiben unverändert.
|
||||
from agents.claude_client import _ai_backend_var
|
||||
from config import AI_BACKEND
|
||||
_backend = _ai_backend_var.get(None) or AI_BACKEND
|
||||
if _backend == "bedrock":
|
||||
from agents.eu_researcher import run_eu_research
|
||||
result, usage = await run_eu_research(
|
||||
title=title,
|
||||
description=description,
|
||||
incident_type=incident_type,
|
||||
lang_instruction=lang_instruction,
|
||||
existing_context=existing_context,
|
||||
preferred_sources_block=preferred_sources_block,
|
||||
output_language=output_language,
|
||||
excluded_sources=await self._get_excluded_sources(user_id=user_id),
|
||||
research_language_iso=research_language_iso or output_language_iso,
|
||||
)
|
||||
else:
|
||||
result, usage = await call_claude(prompt)
|
||||
try:
|
||||
articles = self._parse_response(result)
|
||||
except ResearcherParseError as parse_err:
|
||||
@@ -892,6 +922,20 @@ class ResearcherAgent:
|
||||
if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
|
||||
return obj["articles"]
|
||||
|
||||
# 3b) Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche
|
||||
# Zitate in content_summary). Ohne diesen Schritt fiel die Auswertung
|
||||
# auf die Einzelobjekt-Rettung zurueck, die Artikel verlieren kann, oder
|
||||
# es ging ein kompletter Recherche-Durchlauf verloren.
|
||||
from json_utils import extract_json_array as _forgiving_array, extract_json_object as _forgiving_object
|
||||
repaired_arr = _forgiving_array(text)
|
||||
if isinstance(repaired_arr, list):
|
||||
logger.info("JSON-Reparatur: %d Artikel gerettet", len(repaired_arr))
|
||||
return repaired_arr
|
||||
repaired_obj = _forgiving_object(text)
|
||||
if isinstance(repaired_obj, dict) and isinstance(repaired_obj.get("articles"), list):
|
||||
logger.info("JSON-Reparatur: %d Artikel gerettet (Objektform)", len(repaired_obj["articles"]))
|
||||
return repaired_obj["articles"]
|
||||
|
||||
# 4) Recovery: einzelne Headline-Objekte aus Fliesstext
|
||||
recovered = []
|
||||
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):
|
||||
|
||||
119
src/config.py
119
src/config.py
@@ -34,6 +34,125 @@ CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-
|
||||
CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse)
|
||||
CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck
|
||||
|
||||
# --- KI-Backend (EU/DSGVO-Umbau, Phase 1) -------------------------------------
|
||||
# 'cli' = heutiger Weg über das Claude CLI (Anthropic, Abo-Konto).
|
||||
# 'bedrock' = AWS Bedrock über EU-Inferenzprofile (Frankfurt). Verarbeitung
|
||||
# bleibt in Europa. Phase 1 bedient nur werkzeuglose Aufrufe,
|
||||
# WebSearch-Aufrufe laufen weiter übers CLI, bis die
|
||||
# staan-Recherche-Schleife (Phase 2) fertig ist.
|
||||
# Auflösung je Refresh in agents/orchestrator.py. Lage (incidents.ai_backend)
|
||||
# gewinnt vor Org-Setting 'ai_backend', das vor diesem globalen Default.
|
||||
AI_BACKEND = os.environ.get("AI_BACKEND", "cli").strip().lower()
|
||||
BEDROCK_REGION = os.environ.get("BEDROCK_REGION") or os.environ.get("AWS_REGION", "eu-central-1")
|
||||
# Obergrenze je Bedrock-Antwort. In Phase 1 laufen nur JSON-Aufgaben über
|
||||
# Bedrock (Feed-Selektion, Topic-Filter, Geoparsing, Übersetzung, QC),
|
||||
# dafür reichen 16k gut aus. Große Lagebilder bleiben in Phase 1 beim CLI.
|
||||
BEDROCK_MAX_TOKENS = int(os.environ.get("BEDROCK_MAX_TOKENS", "16000"))
|
||||
# Abbildung der CLI-Modellnamen auf EU-Inferenzprofile. NUR eu.-Profile
|
||||
# eintragen, agents/bedrock_client.py verweigert alles andere.
|
||||
# Standard-Stufe ist Opus 4.6, weil das AWS-Konto die neuesten Modelle
|
||||
# (Opus 4.7/5, Sonnet 5) Stand 30.07.2026 nicht aufrufen darf
|
||||
# (AccessDeniedException, zusätzliche Freischaltung bei AWS nötig).
|
||||
# Nach der Freischaltung reicht BEDROCK_PROFILE_STANDARD in der .env,
|
||||
# z.B. eu.anthropic.claude-opus-4-7.
|
||||
BEDROCK_MODEL_MAP = {
|
||||
CLAUDE_MODEL_FAST: os.environ.get("BEDROCK_PROFILE_FAST", "eu.anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||
CLAUDE_MODEL_MEDIUM: os.environ.get("BEDROCK_PROFILE_MEDIUM", "eu.anthropic.claude-sonnet-4-6"),
|
||||
CLAUDE_MODEL_STANDARD: os.environ.get("BEDROCK_PROFILE_STANDARD", "eu.anthropic.claude-opus-4-6-v1"),
|
||||
}
|
||||
# Preise in USD je 1 Mio Token (Stand 29.07.2026, Anthropic-Listenpreise,
|
||||
# Bedrock rechnet für Anthropic-Modelle zu denselben Sätzen ab). Nach der
|
||||
# ersten AWS-Rechnung gegen die tatsächlichen Beträge verifizieren.
|
||||
BEDROCK_PRICING = {
|
||||
CLAUDE_MODEL_FAST: {"input": 1.00, "output": 5.00},
|
||||
CLAUDE_MODEL_MEDIUM: {"input": 3.00, "output": 15.00},
|
||||
CLAUDE_MODEL_STANDARD: {"input": 5.00, "output": 25.00},
|
||||
}
|
||||
BEDROCK_CACHE_WRITE_FACTOR = 1.25
|
||||
BEDROCK_CACHE_READ_FACTOR = 0.10
|
||||
# Wartestufen in Sekunden, wenn Bedrock keine Kapazitaet hat
|
||||
# (ServiceUnavailableException, HTTP 503) oder unsere Quote greift
|
||||
# (ThrottlingException, HTTP 429). Botocore gibt nach rund fuenf Sekunden auf,
|
||||
# genau in dem Fenster, in dem sich eine Kapazitaetsdelle meist von selbst
|
||||
# loest. Am 01.08.2026 kostete das einen kompletten Pipeline-Schritt
|
||||
# (Web-Source-Selektion), ohne dass es im Bericht sichtbar wurde.
|
||||
BEDROCK_RETRY_WAITS = [
|
||||
float(x) for x in os.environ.get("BEDROCK_RETRY_WAITS", "5,15,30").split(",") if x.strip()
|
||||
]
|
||||
# Obergrenze fuer die Summe aller Wartezeiten innerhalb eines Refreshs. Ohne
|
||||
# Deckel koennten mehrere Engpaesse einen Lauf spuerbar verlaengern.
|
||||
BEDROCK_RETRY_BUDGET_S = float(os.environ.get("BEDROCK_RETRY_BUDGET_S", "120"))
|
||||
# Sicherheitsabstand: Es wird nur gewartet, wenn danach noch mindestens so
|
||||
# viele Sekunden vom Zeitbudget des Aufrufs uebrig sind. Die Wartezeit zaehlt
|
||||
# gegen dasselbe Budget wie der Aufruf selbst (asyncio.wait), und die
|
||||
# Planungsaufrufe an Haiku haben nur 120 Sekunden.
|
||||
BEDROCK_RETRY_MIN_REST_S = float(os.environ.get("BEDROCK_RETRY_MIN_REST_S", "12"))
|
||||
|
||||
# --- staan.ai Such-API (EU/DSGVO-Umbau, Phase 2) -------------------------------
|
||||
# Europäischer Suchindex (European Search Perspective). Ersetzt im EU-Modellweg
|
||||
# die eingebaute WebSearch/WebFetch des Claude CLI. Client in
|
||||
# services/staan_client.py, Recherche-Schleife in agents/eu_researcher.py.
|
||||
STAAN_API_KEY = os.environ.get("STAAN_API_KEY", "")
|
||||
STAAN_BASE_URL = os.environ.get("STAAN_BASE_URL", "https://api.staan.ai/v2")
|
||||
# 2 Euro je 1000 Anfragen, als USD-Näherung für die interne Kostenkontrolle.
|
||||
STAAN_COST_PER_QUERY_USD = float(os.environ.get("STAAN_COST_PER_QUERY_USD", "0.0023"))
|
||||
# Pflicht-Ausschlussliste je Suchanfrage (Befund aus Phase 0, hebt die
|
||||
# Trefferqualität deutlich). staan erlaubt maximal 10 Domains je Anfrage,
|
||||
# Nutzer-Ausschlüsse füllen die restlichen Plätze auf.
|
||||
STAAN_EXCLUDE_DOMAINS = [
|
||||
"youtube.com", "wikipedia.org", "facebook.com", "reddit.com",
|
||||
"pinterest.com", "tiktok.com", "instagram.com", "x.com",
|
||||
]
|
||||
# Rundenbegrenzung der EU-Recherche-Schleife (Planungsaufrufe an das Modell,
|
||||
# ohne die Abschlussrunde). research bildet die 4-Phasen-Tiefenrecherche nach.
|
||||
EU_RESEARCH_MAX_ROUNDS_ADHOC = int(os.environ.get("EU_RESEARCH_MAX_ROUNDS_ADHOC", "3"))
|
||||
EU_RESEARCH_MAX_ROUNDS_RESEARCH = int(os.environ.get("EU_RESEARCH_MAX_ROUNDS_RESEARCH", "5"))
|
||||
# Neue Treffer je Runde. Ohne dieses Kontingent fuellten die ersten beiden
|
||||
# Runden (breite Erfassung) den Korb bis zur Obergrenze, und die letzte Runde,
|
||||
# die gezielt Luecken schliessen soll, lief nie: In den Laeufen vom 01. und
|
||||
# 02.08.2026 endete die Suchphase jedes Mal mit "Treffer-Obergrenze erreicht"
|
||||
# nach Runde 2. Das Kontingent haelt fuer die spaeteren Runden Platz frei.
|
||||
EU_RESEARCH_MAX_NEW_PER_ROUND = int(os.environ.get("EU_RESEARCH_MAX_NEW_PER_ROUND", "25"))
|
||||
# --- Belegsuche des Faktenchecks (EU-Modus) -----------------------------------
|
||||
# Vorher pauschal 3 Anfragen fuer alle Behauptungen zusammen. Der Vergleich am
|
||||
# 31.07.2026 zeigte, dass die EU-Fassung dadurch systematisch weniger Fakten
|
||||
# bestaetigen konnte als der heutige Weg, der je Behauptung nachsuchen darf.
|
||||
# Jetzt gezielt je Behauptung geplant, breiter, und mit einer Nachrunde fuer
|
||||
# die Punkte, die im ersten Durchgang offen geblieben sind.
|
||||
EU_VERIFY_MAX_QUERIES = int(os.environ.get("EU_VERIFY_MAX_QUERIES", "7"))
|
||||
EU_VERIFY_MAX_ITEMS = int(os.environ.get("EU_VERIFY_MAX_ITEMS", "14"))
|
||||
EU_VERIFY_HITS_PER_QUERY = int(os.environ.get("EU_VERIFY_HITS_PER_QUERY", "6"))
|
||||
# Nachhak-Runde: Anzahl zusaetzlicher Anfragen und Obergrenze offener
|
||||
# Behauptungen, die eine Nachrunde ausloesen. 0 schaltet die Nachrunde ab.
|
||||
EU_VERIFY_FOLLOWUP_QUERIES = int(os.environ.get("EU_VERIFY_FOLLOWUP_QUERIES", "5"))
|
||||
EU_VERIFY_FOLLOWUP_MIN_OPEN = int(os.environ.get("EU_VERIFY_FOLLOWUP_MIN_OPEN", "2"))
|
||||
# Faktenpruefung in Themengruppen. Mehrere Gruppen laufen parallel und jede
|
||||
# deckt nur ein Teilthema ab, deshalb kleinere Suchmengen als beim Gesamtlauf.
|
||||
EU_VERIFY_GROUP_QUERIES = int(os.environ.get("EU_VERIFY_GROUP_QUERIES", "4"))
|
||||
EU_VERIFY_GROUP_FOLLOWUP_QUERIES = int(os.environ.get("EU_VERIFY_GROUP_FOLLOWUP_QUERIES", "3"))
|
||||
# Anteil der Belegsuchen, der gezielt nach Widerspruch sucht statt nach
|
||||
# Bestaetigung. Ohne Gegenproben sieht der Faktencheck nur stuetzendes Material
|
||||
# und bestaetigt praktisch jede Behauptung.
|
||||
EU_VERIFY_COUNTER_SHARE = float(os.environ.get("EU_VERIFY_COUNTER_SHARE", "0.3"))
|
||||
# Anfragen, fuer die der Artikeltext geholt wird. Nur damit kann das Modell
|
||||
# pruefen, ob eine Quelle die Behauptung wirklich traegt, statt aus der
|
||||
# Ueberschrift zu schliessen.
|
||||
EU_VERIFY_FULLTEXT_QUERIES = int(os.environ.get("EU_VERIFY_FULLTEXT_QUERIES", "3"))
|
||||
EU_VERIFY_FULLTEXT_CHARS = int(os.environ.get("EU_VERIFY_FULLTEXT_CHARS", "1800"))
|
||||
|
||||
# Wie viele Faktenaussagen ein Faktencheck aufstellen soll. Der Richtwert waechst
|
||||
# mit der Menge des Materials. Eine feste Obergrenze von zehn fuehrte dazu, dass
|
||||
# aus 16 Meldungen genauso viele Fakten wurden wie aus 91, obwohl die Lage im
|
||||
# zweiten Fall deutlich mehr pruefbare Punkte hergibt.
|
||||
FAKTEN_JE_ARTIKEL = int(os.environ.get("FAKTEN_JE_ARTIKEL", "5"))
|
||||
FAKTEN_MIN = int(os.environ.get("FAKTEN_MIN", "5"))
|
||||
FAKTEN_MAX = int(os.environ.get("FAKTEN_MAX", "20"))
|
||||
# Dasselbe fuer die Aktualisierung, dort zaehlen nur die neu hinzugekommenen
|
||||
# Meldungen.
|
||||
FAKTEN_NEU_JE_ARTIKEL = int(os.environ.get("FAKTEN_NEU_JE_ARTIKEL", "5"))
|
||||
FAKTEN_NEU_MIN = int(os.environ.get("FAKTEN_NEU_MIN", "3"))
|
||||
FAKTEN_NEU_MAX = int(os.environ.get("FAKTEN_NEU_MAX", "10"))
|
||||
|
||||
# --- Verbrauchssaetze (Credits je Aktion) -------------------------------------
|
||||
# Beschlossen 2026-07-23, der Verbrauchsrechner im Verwaltungsportal nutzt
|
||||
# dieselben Werte: Live-Lauf 45, Recherche je Durchlauf 40 (das Anlegen faehrt
|
||||
|
||||
@@ -392,7 +392,10 @@ async def get_db() -> aiosqlite.Connection:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA foreign_keys=ON")
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
# 30s statt 5s seit der Doppelspur (EU-Umbau Phase 2). Zwei gleichzeitig
|
||||
# laufende Refreshes derselben Organisation schreiben parallel in die DB,
|
||||
# 5s reichten dabei nicht immer (database is locked beim Summary-Update).
|
||||
await db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
|
||||
@@ -511,6 +514,14 @@ async def init_db():
|
||||
await db.commit()
|
||||
logger.info("Migration: executive_summary zu incidents hinzugefuegt (Studio)")
|
||||
|
||||
# Migration (EU-Umbau Phase 1): KI-Backend je Lage ('cli' oder 'bedrock').
|
||||
# NULL heisst, die Vorgabe der Organisation bzw. der globale Default
|
||||
# AI_BACKEND aus config.py greift. Aufloesung im Orchestrator.
|
||||
if "ai_backend" not in columns:
|
||||
await db.execute("ALTER TABLE incidents ADD COLUMN ai_backend TEXT DEFAULT NULL")
|
||||
await db.commit()
|
||||
logger.info("Migration: ai_backend zu incidents hinzugefuegt (EU-Umbau)")
|
||||
|
||||
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
||||
|
||||
@@ -18,6 +18,30 @@ from agents.researcher import keywords_for_language, flatten_keywords
|
||||
logger = logging.getLogger("osint.rss")
|
||||
|
||||
|
||||
def wort_trifft(wort: str, text: str) -> bool:
|
||||
"""Prueft ein Suchwort gegen den Text, Mehrwort-Begriffe wortweise.
|
||||
|
||||
Die Keyword-Erzeugung liefert je nach Lagentitel Einzelbegriffe ("ceuta")
|
||||
oder Phrasen ("migrationskrise spanien"). Ein reiner Teilstring-Vergleich
|
||||
laesst Phrasen praktisch immer durchfallen: Die Schlagzeile "Migrationskrise
|
||||
in Spanien" enthaelt die Folge "migrationskrise spanien" nicht, wegen des
|
||||
Wortes dazwischen. Am 01.08.2026 kostete das den Unterschied zwischen 79
|
||||
und 17 RSS-Treffern fuer dieselbe Lage.
|
||||
|
||||
Deshalb: Eine Phrase trifft, wenn ALLE ihre Bestandteile im Text vorkommen,
|
||||
unabhaengig von Reihenfolge und Abstand. Die Genauigkeit bleibt damit
|
||||
erhalten, nur die starre Reihenfolge faellt weg.
|
||||
"""
|
||||
if not wort:
|
||||
return False
|
||||
if wort in text:
|
||||
return True
|
||||
teile = [t for t in wort.split() if t]
|
||||
if len(teile) < 2:
|
||||
return False
|
||||
return all(t in text for t in teile)
|
||||
|
||||
|
||||
def _is_specific_word(w: str) -> bool:
|
||||
"""Spezifisches Keyword = 1-Treffer reicht für Match.
|
||||
|
||||
@@ -209,12 +233,14 @@ class RSSParser:
|
||||
# CJK/Arabisch/Hebräisch/Kyrillisch ≥3 Zeichen) im Text reicht 1 Treffer.
|
||||
# Damit matched z.B. "自衛隊" (3 Kanji) wie "buckelwal" (9 Zeichen).
|
||||
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
|
||||
specific_in_text = any(w in text for w in search_words if _is_specific_word(w))
|
||||
specific_in_text = any(
|
||||
wort_trifft(w, text) for w in search_words if _is_specific_word(w)
|
||||
)
|
||||
if specific_in_text:
|
||||
min_matches = 1
|
||||
else:
|
||||
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
|
||||
match_count = sum(1 for word in search_words if word in text)
|
||||
match_count = sum(1 for word in search_words if wort_trifft(word, text))
|
||||
|
||||
if match_count >= min_matches:
|
||||
published = None
|
||||
|
||||
250
src/json_utils.py
Normale Datei
250
src/json_utils.py
Normale Datei
@@ -0,0 +1,250 @@
|
||||
"""Nachsichtiges Auslesen von JSON aus Modell-Antworten.
|
||||
|
||||
Hintergrund. Die Agenten verlangen von den Modellen JSON. Gelegentlich
|
||||
scheitert das an einer Kleinigkeit, mit Abstand am haeufigsten an einem nicht
|
||||
maskierten geraden Anfuehrungszeichen mitten in einem Textwert. Typisch ist ein
|
||||
deutsches Zitat, das mit dem unteren Zeichen geoeffnet und mit einem geraden
|
||||
Zeichen geschlossen wird:
|
||||
|
||||
{"summary": "Sánchez nannte es „Angriff auf die Integritaet" und kuendigte..."}
|
||||
^ bricht das JSON
|
||||
|
||||
Der Standard-Parser bricht dort ab. Die nachgelagerten Notfall-Pfade der Agenten
|
||||
retten dann oft nur das Bruchstueck bis zu dieser Stelle, was still zu
|
||||
abgeschnittenen Lagebildern und verlorenen Recherche-Ergebnissen fuehrt
|
||||
(beobachtet am 31.07.2026 auf Staging, vor allem auf dem EU-Modellweg).
|
||||
|
||||
Dieses Modul repariert genau solche Faelle. Wichtig fuer die Sicherheit,
|
||||
repariert wird ausschliesslich als Rueckfallebene, nachdem der normale Parser
|
||||
gescheitert ist. Gueltiges JSON wird nie angefasst.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("osint.json_utils")
|
||||
|
||||
# Zeichen, die nach einem schliessenden Anfuehrungszeichen eindeutig fuer ein
|
||||
# echtes Kettenende sprechen. Beim Komma genuegt das nicht, weil auch im
|
||||
# Fliesstext ein Zitat vor einem Komma stehen kann. Dort wird zusaetzlich
|
||||
# geprueft, ob danach ueberhaupt ein gueltiger JSON-Wert beginnt.
|
||||
_STRING_END_FOLLOWERS = "}]:"
|
||||
_WHITESPACE = " \t\r\n"
|
||||
_ESCAPE_IN_STRING = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
||||
# Zeichen, mit denen ein JSON-Wert oder Schluessel beginnen kann
|
||||
_VALUE_STARTERS = '"{['
|
||||
_LITERALS = ("true", "false", "null")
|
||||
|
||||
|
||||
def _endet_kette(text: str, pos: int) -> bool:
|
||||
"""Beurteilt, ob das Anfuehrungszeichen an pos eine Zeichenkette beendet."""
|
||||
n = len(text)
|
||||
j = pos + 1
|
||||
while j < n and text[j] in _WHITESPACE:
|
||||
j += 1
|
||||
if j >= n:
|
||||
return True
|
||||
if text[j] in _STRING_END_FOLLOWERS:
|
||||
return True
|
||||
if text[j] != ",":
|
||||
return False
|
||||
# Komma. Ein echtes Kettenende wird von einem neuen Wert oder Schluessel
|
||||
# gefolgt, im Fliesstext dagegen von gewoehnlichen Woertern.
|
||||
k = j + 1
|
||||
while k < n and text[k] in _WHITESPACE:
|
||||
k += 1
|
||||
if k >= n:
|
||||
return True
|
||||
if text[k] in _VALUE_STARTERS or text[k].isdigit() or text[k] == "-":
|
||||
return True
|
||||
rest = text[k:k + 5]
|
||||
return any(rest.startswith(lit) for lit in _LITERALS)
|
||||
|
||||
|
||||
def repair_json_text(text: str) -> str:
|
||||
"""Maskiert Anfuehrungszeichen und Zeilenumbrueche, die faelschlich roh in
|
||||
JSON-Textwerten stehen.
|
||||
|
||||
Verfahren. Der Text wird einmal zeichenweise durchlaufen und mitgefuehrt, ob
|
||||
wir uns gerade in einer Zeichenkette befinden. Trifft der Durchlauf innerhalb
|
||||
einer Zeichenkette auf ein Anfuehrungszeichen, entscheidet ein Blick auf das
|
||||
naechste nicht-leere Zeichen, ob es die Kette wirklich beendet. Steht dort
|
||||
Fliesstext statt eines Struktur-Zeichens, wird maskiert statt beendet.
|
||||
"""
|
||||
out: list[str] = []
|
||||
in_string = False
|
||||
escaped = False
|
||||
i = 0
|
||||
n = len(text)
|
||||
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
|
||||
if escaped:
|
||||
out.append(ch)
|
||||
escaped = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "\\":
|
||||
out.append(ch)
|
||||
escaped = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
if not in_string:
|
||||
in_string = True
|
||||
out.append(ch)
|
||||
elif _endet_kette(text, i):
|
||||
in_string = False
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append('\\"')
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_string and ch in _ESCAPE_IN_STRING:
|
||||
out.append(_ESCAPE_IN_STRING[ch])
|
||||
i += 1
|
||||
continue
|
||||
|
||||
out.append(ch)
|
||||
i += 1
|
||||
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def loads_forgiving(text: str, context: str = "") -> Any | None:
|
||||
"""json.loads mit Reparatur-Rueckfallebene. Gibt None zurueck, wenn auch die
|
||||
Reparatur nichts Verwertbares ergibt."""
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
data = json.loads(repair_json_text(text))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
logger.info("JSON-Reparatur erfolgreich%s (%d Zeichen)", f" [{context}]" if context else "", len(text))
|
||||
return data
|
||||
|
||||
|
||||
def _strip_fences(text: str) -> str:
|
||||
"""Entfernt umschliessende Markdown-Code-Zaeune."""
|
||||
cleaned = (text or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
nl = cleaned.find("\n")
|
||||
if nl != -1:
|
||||
cleaned = cleaned[nl + 1:]
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3].rstrip()
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _extract_block(text: str, opener: str, closer: str, want: type) -> Any | None:
|
||||
"""Sucht den ersten vollstaendigen JSON-Block der gewuenschten Art, erst
|
||||
unveraendert, dann repariert."""
|
||||
if not text:
|
||||
return None
|
||||
for candidate in (text, repair_json_text(text)):
|
||||
decoder = json.JSONDecoder()
|
||||
idx = 0
|
||||
while True:
|
||||
start = candidate.find(opener, idx)
|
||||
if start == -1:
|
||||
break
|
||||
try:
|
||||
obj, _ = decoder.raw_decode(candidate, start)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
idx = start + 1
|
||||
continue
|
||||
if isinstance(obj, want):
|
||||
return obj
|
||||
idx = start + 1
|
||||
# Zweiter Durchgang nur, wenn die Reparatur ueberhaupt etwas geaendert hat
|
||||
if candidate is not text:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _extract_all_blocks(text: str, opener: str, closer: str, want: type) -> list:
|
||||
"""Alle vollstaendigen JSON-Bloecke der gewuenschten Art, in Reihenfolge.
|
||||
|
||||
Modelle korrigieren sich gelegentlich selbst und haengen nach einer ersten
|
||||
Antwort eine zweite, vollstaendigere an, etwa mit einem Hinweis wie "Let me
|
||||
redo this properly". Wer nur den ersten Block nimmt, verliert die bessere
|
||||
Fassung.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
for candidate in (text, repair_json_text(text)):
|
||||
decoder = json.JSONDecoder()
|
||||
gefunden = []
|
||||
idx = 0
|
||||
while True:
|
||||
start = candidate.find(opener, idx)
|
||||
if start == -1:
|
||||
break
|
||||
try:
|
||||
obj, ende = decoder.raw_decode(candidate, start)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
idx = start + 1
|
||||
continue
|
||||
if isinstance(obj, want):
|
||||
gefunden.append(obj)
|
||||
idx = ende
|
||||
else:
|
||||
idx = start + 1
|
||||
if gefunden:
|
||||
return gefunden
|
||||
if candidate is not text:
|
||||
break
|
||||
return []
|
||||
|
||||
|
||||
def extract_json_objects(text: str) -> list[dict]:
|
||||
"""Alle vollstaendigen JSON-Objekte im Text, mit Reparatur-Rueckfall."""
|
||||
cleaned = _strip_fences(text)
|
||||
direct = loads_forgiving(cleaned)
|
||||
if isinstance(direct, dict):
|
||||
return [direct]
|
||||
return _extract_all_blocks(text, "{", "}", dict)
|
||||
|
||||
|
||||
def extract_json_arrays(text: str) -> list[list]:
|
||||
"""Alle vollstaendigen JSON-Arrays im Text, mit Reparatur-Rueckfall."""
|
||||
cleaned = _strip_fences(text)
|
||||
direct = loads_forgiving(cleaned)
|
||||
if isinstance(direct, list):
|
||||
return [direct]
|
||||
return _extract_all_blocks(text, "[", "]", list)
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> dict | None:
|
||||
"""Erstes vollstaendiges JSON-Objekt im Text, mit Reparatur-Rueckfall."""
|
||||
cleaned = _strip_fences(text)
|
||||
direct = loads_forgiving(cleaned)
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
return _extract_block(text, "{", "}", dict)
|
||||
|
||||
|
||||
def extract_json_array(text: str) -> list | None:
|
||||
"""Erstes vollstaendiges JSON-Array im Text, mit Reparatur-Rueckfall."""
|
||||
cleaned = _strip_fences(text)
|
||||
direct = loads_forgiving(cleaned)
|
||||
if isinstance(direct, list):
|
||||
return direct
|
||||
return _extract_block(text, "[", "]", list)
|
||||
|
||||
|
||||
def looks_truncated(text: str) -> bool:
|
||||
"""Heuristik, ob ein geretteter Text mitten im Satz abbricht. Dient nur dem
|
||||
Protokoll, damit stille Teilverluste sichtbar werden."""
|
||||
s = (text or "").rstrip()
|
||||
if not s:
|
||||
return True
|
||||
return s[-1] not in ".!?)]}\"'…:*_`-"
|
||||
@@ -62,6 +62,9 @@ class IncidentCreate(BaseModel):
|
||||
include_telegram: bool = False
|
||||
include_x: bool = False
|
||||
visibility: str = Field(default="public", pattern="^(public|private)$")
|
||||
# KI-Weg dieser Lage (EU-Umbau). Leer/None = Vorgabe der Organisation
|
||||
# bzw. globaler Default. 'cli' = Anthropic, 'bedrock' = EU (Frankfurt + staan).
|
||||
ai_backend: Optional[str] = Field(default=None, pattern="^(cli|bedrock)$")
|
||||
|
||||
|
||||
class IncidentUpdate(BaseModel):
|
||||
@@ -77,6 +80,10 @@ class IncidentUpdate(BaseModel):
|
||||
include_telegram: Optional[bool] = None
|
||||
include_x: Optional[bool] = None
|
||||
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
||||
# KI-Weg. Leerstring "" setzt bewusst auf "Vorgabe der Organisation" zurueck,
|
||||
# deshalb hier auch der Leerstring erlaubt (exclude_none im Router laesst
|
||||
# None durchfallen, "" kommt an und wird zu NULL).
|
||||
ai_backend: Optional[str] = Field(default=None, pattern="^(cli|bedrock|)$")
|
||||
|
||||
|
||||
class DescriptionEnhanceRequest(BaseModel):
|
||||
@@ -108,6 +115,7 @@ class IncidentResponse(BaseModel):
|
||||
international_sources: bool = True
|
||||
include_telegram: bool = False
|
||||
include_x: bool = False
|
||||
ai_backend: Optional[str] = None
|
||||
created_by: int
|
||||
created_by_username: str = ""
|
||||
created_at: str
|
||||
@@ -137,6 +145,7 @@ class IncidentListItem(BaseModel):
|
||||
international_sources: bool = True
|
||||
include_telegram: bool = False
|
||||
include_x: bool = False
|
||||
ai_backend: Optional[str] = None
|
||||
created_by: int
|
||||
created_by_username: str = ""
|
||||
created_at: str
|
||||
|
||||
@@ -19,6 +19,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
|
||||
from config import TIMEZONE, CLAUDE_MODEL_FAST
|
||||
from services import media_registry
|
||||
|
||||
logger = logging.getLogger("osint.report")
|
||||
|
||||
@@ -30,23 +31,27 @@ FC_STATUS_LABELS_DE = {
|
||||
# 1:1 vom Monitor-Frontend (components.js) — konsistent zum UI.
|
||||
"confirmed": "Bestätigt",
|
||||
"unconfirmed": "Unbestätigt",
|
||||
"contradicted": "Widerlegt",
|
||||
# "Widerlegt" war hier falsch: Der Status meint laut Auftrag Belege, die
|
||||
# einander widersprechen. Im Bericht vom 01.08.2026 stand deshalb eine
|
||||
# sachlich zutreffende Zahlenspanne als widerlegt. Die echte Widerlegung
|
||||
# hat jetzt den eigenen Status "false".
|
||||
"contradicted": "Widersprüchlich",
|
||||
"developing": "Unklar",
|
||||
"established": "Gesichert",
|
||||
"disputed": "Umstritten",
|
||||
"unverified": "Ungeprüft",
|
||||
"false": "Falsch",
|
||||
"false": "Widerlegt",
|
||||
}
|
||||
|
||||
FC_STATUS_LABELS_EN = {
|
||||
"confirmed": "Confirmed",
|
||||
"unconfirmed": "Unconfirmed",
|
||||
"contradicted": "Contradicted",
|
||||
"contradicted": "Conflicting",
|
||||
"developing": "Developing",
|
||||
"established": "Established",
|
||||
"disputed": "Disputed",
|
||||
"unverified": "Unverified",
|
||||
"false": "False",
|
||||
"false": "Disproved",
|
||||
}
|
||||
|
||||
|
||||
@@ -68,29 +73,244 @@ def _get_logo_base64() -> str:
|
||||
|
||||
|
||||
def _prepare_sources(incident: dict) -> list:
|
||||
"""Quellenverzeichnis aus sources_json parsen."""
|
||||
"""Quellenverzeichnis aus sources_json parsen.
|
||||
|
||||
Die Nummer aus dem Lagebild (nr) ist die Identitaet des Eintrags, sie wird
|
||||
hier gesichert und die Liste danach sortiert. Der Anzeigename wird je
|
||||
Domain vereinheitlicht, damit derselbe Verlag nicht unter mehreren Namen
|
||||
im Verzeichnis steht.
|
||||
"""
|
||||
raw = incident.get("sources_json")
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
return json.loads(raw) if isinstance(raw, str) else raw
|
||||
roh = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if not isinstance(roh, list):
|
||||
return []
|
||||
|
||||
eintraege = [s for s in roh if isinstance(s, dict)]
|
||||
namen = media_registry.namen_je_domain(
|
||||
[(s.get("url") or "", s.get("name") or "") for s in eintraege]
|
||||
)
|
||||
|
||||
aufbereitet = []
|
||||
for position, s in enumerate(eintraege, 1):
|
||||
kopie = dict(s)
|
||||
kopie["nr"] = _als_nummer(s.get("nr"), position)
|
||||
url = s.get("url") or ""
|
||||
domain = media_registry.registrable_domain(url)
|
||||
# Bei Weiterleitungsportalen sagt die Domain nichts ueber das Medium:
|
||||
# vier verschiedene Zeitungen liegen dort alle unter news.google.com.
|
||||
# Der gelieferte Name bleibt deshalb unangetastet.
|
||||
if domain and not media_registry.ist_aggregator(url):
|
||||
kopie["name"] = namen.get(domain) or kopie.get("name") or media_registry.name_aus_domain(domain)
|
||||
kopie["domain"] = domain
|
||||
elif domain:
|
||||
kopie["domain"] = domain
|
||||
aufbereitet.append(kopie)
|
||||
|
||||
aufbereitet.sort(key=lambda s: s["nr"] if isinstance(s["nr"], int) else 10**6)
|
||||
return _dubletten_zusammenfuehren(aufbereitet)
|
||||
|
||||
|
||||
def _prepare_source_stats(articles: list) -> list:
|
||||
"""Quellenstatistik: Artikel pro Quelle + Sprachen."""
|
||||
source_map = defaultdict(lambda: {"count": 0, "langs": set()})
|
||||
for art in articles:
|
||||
name = art.get("source") or "Unbekannt"
|
||||
source_map[name]["count"] += 1
|
||||
source_map[name]["langs"].add((art.get("language") or "de").upper())
|
||||
def _url_schluessel(url: str) -> str:
|
||||
"""Vergleichsform einer Adresse: ohne Schema, ohne www, ohne Endschraegstrich."""
|
||||
text = (url or "").strip().lower()
|
||||
for praefix in ("https://", "http://"):
|
||||
if text.startswith(praefix):
|
||||
text = text[len(praefix):]
|
||||
if text.startswith("www."):
|
||||
text = text[4:]
|
||||
return text.rstrip("/")
|
||||
|
||||
|
||||
def _dubletten_zusammenfuehren(sources: list) -> list:
|
||||
"""Fuehrt Eintraege mit identischer Adresse auf eine Nummer zusammen.
|
||||
|
||||
Im Bericht vom 02.08.2026 standen 30 Eintraege fuer 28 verschiedene
|
||||
Adressen: zweimal dieselbe tagesschau-Meldung unter je zwei Nummern. Eine
|
||||
Behauptung wirkte dadurch mit "3 Quellen" belegt, obwohl zwei davon
|
||||
derselbe Artikel waren. Die kleinere Nummer gewinnt, die groessere wird
|
||||
unter "alias_nrs" vermerkt, damit die Verweise im Text auf sie umgebogen
|
||||
werden koennen.
|
||||
"""
|
||||
behalten: dict[str, dict] = {}
|
||||
reihenfolge: list[str] = []
|
||||
ohne_adresse = 0
|
||||
for s in sources:
|
||||
schluessel = _url_schluessel(s.get("url") or "")
|
||||
if not schluessel:
|
||||
# Eintraege ohne Adresse sind keine pruefbaren Belege. Im Bericht
|
||||
# vom 02.08.2026 stand so ein Platzhalter als "12 Quelle" ohne
|
||||
# Adresse im Verzeichnis und als "Quelle 1" in der Statistik.
|
||||
ohne_adresse += 1
|
||||
continue
|
||||
if schluessel in behalten:
|
||||
behalten[schluessel].setdefault("alias_nrs", []).append(s.get("nr"))
|
||||
continue
|
||||
behalten[schluessel] = s
|
||||
reihenfolge.append(schluessel)
|
||||
|
||||
ergebnis = [behalten[k] for k in reihenfolge]
|
||||
entfernt = len(sources) - len(ergebnis) - ohne_adresse
|
||||
if entfernt:
|
||||
logger.info("Quellenverzeichnis: %d doppelte Adresse(n) zusammengefuehrt", entfernt)
|
||||
if ohne_adresse:
|
||||
logger.info("Quellenverzeichnis: %d Eintrag/Eintraege ohne Adresse entfernt", ohne_adresse)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _renumber_sources(sources: list) -> tuple[list, dict]:
|
||||
"""Vergibt fuer die Ausgabe eine lueckenlose Nummerierung ab 1.
|
||||
|
||||
Das Modell nummeriert alle vorgelegten Meldungen durch, zitiert aber nur
|
||||
einen Teil davon. Im Verzeichnis entstehen dadurch Luecken (Lage 53 sprang
|
||||
von 20 auf 22, 24, 26). Fachlich ist das korrekt, fuer den Leser sieht es
|
||||
nach einem Fehler aus. Die gespeicherten Nummern bleiben unangetastet,
|
||||
damit sie ueber Folge-Refreshes stabil bleiben, nur der Bericht zaehlt neu.
|
||||
|
||||
Gibt die neu nummerierten Quellen und die Abbildung alt -> neu zurueck.
|
||||
"""
|
||||
abbildung: dict[int, int] = {}
|
||||
neu = []
|
||||
for position, s in enumerate(sources or [], 1):
|
||||
kopie = dict(s)
|
||||
alt = s.get("nr")
|
||||
if isinstance(alt, int):
|
||||
abbildung[alt] = position
|
||||
# Zusammengefuehrte Dubletten zeigen auf denselben Eintrag, damit ein
|
||||
# Verweis auf die entfernte Nummer nicht ins Leere laeuft.
|
||||
for alias in kopie.pop("alias_nrs", []) or []:
|
||||
if isinstance(alias, int):
|
||||
abbildung[alias] = position
|
||||
kopie["nr"] = position
|
||||
neu.append(kopie)
|
||||
return neu, abbildung
|
||||
|
||||
|
||||
def _apply_citation_map(text: str, abbildung: dict) -> str:
|
||||
"""Schreibt [alte Nummer] auf [neue Nummer] um.
|
||||
|
||||
Verweise ohne Quelle im Verzeichnis werden entfernt statt umgeschrieben.
|
||||
Sie waeren nach der Umnummerierung nicht nur unaufloesbar, sondern wuerden
|
||||
auf einen fremden Eintrag zeigen. Ein stiller Verweis ins Leere ist der
|
||||
schwerere Fehler als eine fehlende Klammer.
|
||||
"""
|
||||
if not text or not abbildung:
|
||||
return text
|
||||
|
||||
verwaist: list[str] = []
|
||||
|
||||
def ersetze(m: re.Match) -> str:
|
||||
roh = m.group(1)
|
||||
nummer = _als_nummer(roh, -1)
|
||||
if nummer in abbildung:
|
||||
return f"[{abbildung[nummer]}]"
|
||||
verwaist.append(roh)
|
||||
return ""
|
||||
|
||||
ergebnis = re.sub(r"\[(\d{1,5}[a-z]?)\]", ersetze, text)
|
||||
# Zeigten zwei Nummern auf dieselbe Adresse, landen ihre Verweise nach der
|
||||
# Umschreibung auf derselben Zahl und stehen dann doppelt nebeneinander:
|
||||
# "[24][24]". Im Bericht vom 02.08.2026 kam das dreimal vor.
|
||||
ergebnis = re.sub(r"(\[(\d{1,5})\])(?:\s*\[\2\])+", r"\1", ergebnis)
|
||||
if verwaist:
|
||||
logger.warning(
|
||||
"Bericht: %d Quellenverweise ohne Verzeichniseintrag entfernt (%s)",
|
||||
len(verwaist), ", ".join(sorted(set(verwaist))[:10]),
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _als_nummer(wert, ersatz: int) -> int:
|
||||
"""Quellennummer als ganze Zahl, Buchstaben-Suffixe werden abgeschnitten."""
|
||||
if isinstance(wert, int):
|
||||
return wert
|
||||
text = str(wert or "").strip()
|
||||
m = re.match(r"^(\d+)", text)
|
||||
return int(m.group(1)) if m else ersatz
|
||||
|
||||
|
||||
def _prepare_source_stats(sources: list, articles: list) -> list:
|
||||
"""Quellenstatistik ueber die im Lagebild zitierten Belege.
|
||||
|
||||
Frueher zaehlte die Statistik alle gesammelten Artikel der Lage, waehrend
|
||||
das Quellenverzeichnis darunter nur die zitierten Quellen auflistete. Die
|
||||
beiden Tabellen widersprachen sich damit in jedem Bericht (im Ceuta-Fall
|
||||
49 gezaehlte Artikel gegen 25 aufgefuehrte Quellen). Jetzt bezieht sich die
|
||||
Statistik auf dieselbe Grundmenge wie das Verzeichnis: die Summe der Spalte
|
||||
Belege entspricht genau der Zahl der Eintraege.
|
||||
|
||||
Gezaehlt wird je Domain, nicht je Name, damit mehrere Feeds desselben
|
||||
Verlags eine Zeile ergeben. Die Sprache kommt aus den Artikeln derselben
|
||||
Domain, eine eindeutige Sprachangabe in der Adresse hat Vorrang.
|
||||
"""
|
||||
sprachen_je_domain: dict[str, set] = defaultdict(set)
|
||||
for art in articles or []:
|
||||
domain = media_registry.registrable_domain(art.get("source_url") or "")
|
||||
if not domain:
|
||||
continue
|
||||
aus_url = media_registry.sprache_aus_url(art.get("source_url") or "")
|
||||
sprachen_je_domain[domain].add(aus_url or (art.get("language") or "de").upper())
|
||||
|
||||
stats_map: dict[str, dict] = {}
|
||||
for s in sources or []:
|
||||
url = s.get("url") or ""
|
||||
domain = media_registry.registrable_domain(url)
|
||||
if domain and media_registry.ist_aggregator(url):
|
||||
# Weiterleitungsportale sind kein Medium. Zusammenfassen wuerde
|
||||
# hier vier Zeitungen unter einem Namen verschmelzen, also nach
|
||||
# Name gruppieren und die Weiterleitung offen ausweisen.
|
||||
anzeige = f"{s.get('name') or 'Unbekannt'} (Weiterleitung)"
|
||||
schluessel = f"redirect:{anzeige}"
|
||||
else:
|
||||
anzeige = s.get("name") or media_registry.name_aus_domain(domain)
|
||||
schluessel = domain or anzeige
|
||||
eintrag = stats_map.setdefault(
|
||||
schluessel,
|
||||
{"name": anzeige, "count": 0, "langs": set()},
|
||||
)
|
||||
eintrag["count"] += 1
|
||||
aus_url = media_registry.sprache_aus_url(url)
|
||||
if aus_url:
|
||||
eintrag["langs"].add(aus_url)
|
||||
elif domain in sprachen_je_domain:
|
||||
eintrag["langs"].update(sprachen_je_domain[domain])
|
||||
|
||||
stats = []
|
||||
for name, data in sorted(source_map.items(), key=lambda x: -x[1]["count"]):
|
||||
stats.append({"name": name, "count": data["count"], "languages": ", ".join(sorted(data["langs"]))})
|
||||
for data in sorted(stats_map.values(), key=lambda d: (-d["count"], d["name"])):
|
||||
stats.append({
|
||||
"name": data["name"],
|
||||
"count": data["count"],
|
||||
"languages": ", ".join(sorted(data["langs"])),
|
||||
})
|
||||
return stats
|
||||
|
||||
|
||||
def _source_stats_note(sources: list, articles: list) -> str:
|
||||
"""Erlaeuterung unter der Quellenstatistik.
|
||||
|
||||
Haelt fest, worauf sich die Tabelle bezieht und wie viele Meldungen
|
||||
insgesamt ausgewertet wurden. Ohne diesen Satz wirkte die frueher groessere
|
||||
Artikelzahl wie eine Aufwertung der Quellenbasis.
|
||||
"""
|
||||
zitiert = len(sources or [])
|
||||
gesamt = len(articles or [])
|
||||
medien = len({
|
||||
media_registry.registrable_domain(a.get("source_url") or "")
|
||||
for a in (articles or [])
|
||||
if media_registry.registrable_domain(a.get("source_url") or "")
|
||||
})
|
||||
if not gesamt:
|
||||
return f"Die Tabelle zählt die {zitiert} im Lagebild zitierten Belege, gruppiert nach Medium."
|
||||
return (
|
||||
f"Die Tabelle zählt die {zitiert} im Lagebild zitierten Belege, gruppiert nach Medium. "
|
||||
f"Ausgewertet wurden insgesamt {gesamt} Meldungen aus {medien} Medien."
|
||||
)
|
||||
|
||||
|
||||
def _prepare_fact_checks(fact_checks: list, lang_iso: str = "de") -> list:
|
||||
"""Faktenchecks mit Label aufbereiten."""
|
||||
labels = _fc_labels(lang_iso)
|
||||
@@ -188,8 +408,16 @@ def _parse_developments_for_export(text: str) -> list[tuple[str, str]]:
|
||||
continue
|
||||
yy = year[-2:] if year else year2
|
||||
label = f"{int(day):02d}.{int(month):02d}.{yy}, {time} Uhr"
|
||||
result.append((label, body))
|
||||
return result
|
||||
stunde, minute = (time.split(":") + ["0"])[:2]
|
||||
sortierwert = (int(yy), int(month), int(day), int(stunde), int(minute))
|
||||
result.append((label, body, sortierwert))
|
||||
|
||||
# Neueste zuerst. Der Auftrag verlangt diese Reihenfolge zwar schon vom
|
||||
# Modell, im Bericht vom 02.08.2026 sprang der letzte Eintrag aber von
|
||||
# 12:44 zurueck auf 17:47. Im prominentesten Kapitel darf das nicht von
|
||||
# der Sorgfalt des Modells abhaengen, deshalb hier deterministisch.
|
||||
result.sort(key=lambda e: e[2], reverse=True)
|
||||
return [(label, body) for label, body, _ in result]
|
||||
|
||||
|
||||
def _format_latest_developments_html(text: str) -> str:
|
||||
@@ -782,10 +1010,10 @@ async def generate_pdf(
|
||||
# ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile.
|
||||
# - sonst: KI-Executive-Summary (executive_summary_html).
|
||||
is_research = incident.get("type") == "research"
|
||||
all_sources = _prepare_sources(incident)
|
||||
all_sources, citation_map = _renumber_sources(_prepare_sources(incident))
|
||||
latest_dev = (incident.get("latest_developments") or "").strip()
|
||||
zusammenfassung_html = executive_summary_html
|
||||
bericht_summary = incident.get("summary", "")
|
||||
zusammenfassung_html = _apply_citation_map(executive_summary_html, citation_map)
|
||||
bericht_summary = _apply_citation_map(incident.get("summary", ""), citation_map)
|
||||
zusammenfassung_title = "Zusammenfassung"
|
||||
summary_has_links = True
|
||||
|
||||
@@ -831,9 +1059,13 @@ async def generate_pdf(
|
||||
_markdown_to_html(bericht_summary), all_sources
|
||||
),
|
||||
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
|
||||
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
|
||||
# Keine Kappung mehr. Ein bei 30 abgeschnittenes Verzeichnis machte
|
||||
# jede hoehere Referenz im Text unaufloesbar, der Bericht wies also
|
||||
# Belege aus, die er selbst nicht zeigte.
|
||||
sources=all_sources,
|
||||
fact_checks=_prepare_fact_checks(fact_checks),
|
||||
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
|
||||
source_stats=_prepare_source_stats(all_sources, articles),
|
||||
source_stats_note=_source_stats_note(all_sources, articles),
|
||||
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
||||
articles=articles if scope == "full" else [],
|
||||
meta=meta,
|
||||
@@ -878,10 +1110,12 @@ async def generate_docx(
|
||||
# Zusammenfassungs-Quelle bestimmen (analog generate_pdf):
|
||||
# Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI.
|
||||
is_research = incident.get("type") == "research"
|
||||
all_sources = _prepare_sources(incident)
|
||||
all_sources, citation_map = _renumber_sources(_prepare_sources(incident))
|
||||
latest_dev = (incident.get("latest_developments") or "").strip()
|
||||
zusammenfassung_text = executive_summary_text
|
||||
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
|
||||
zusammenfassung_text = _apply_citation_map(executive_summary_text, citation_map)
|
||||
bericht_summary = _apply_citation_map(
|
||||
incident.get("summary") or "Keine Zusammenfassung verfügbar.", citation_map
|
||||
)
|
||||
zusammenfassung_title = "Zusammenfassung"
|
||||
zusammenfassung_lines: list[str] = []
|
||||
zusammenfassung_developments: list[tuple[str, str]] = []
|
||||
@@ -1043,7 +1277,7 @@ async def generate_docx(
|
||||
|
||||
if "quellen" in sections:
|
||||
# --- Quellenstatistik ---
|
||||
source_stats = _prepare_source_stats(articles)
|
||||
source_stats = _prepare_source_stats(all_sources, articles)
|
||||
if source_stats:
|
||||
doc.add_heading("Quellenstatistik", level=1)
|
||||
table = doc.add_table(rows=1, cols=3)
|
||||
@@ -1051,7 +1285,7 @@ async def generate_docx(
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
hdr = table.rows[0].cells
|
||||
hdr[0].text = "Quelle"
|
||||
hdr[1].text = "Artikel"
|
||||
hdr[1].text = "Belege"
|
||||
hdr[2].text = "Sprache"
|
||||
for cell in hdr:
|
||||
for p in cell.paragraphs:
|
||||
@@ -1062,6 +1296,40 @@ async def generate_docx(
|
||||
row[0].text = stat["name"]
|
||||
row[1].text = str(stat["count"])
|
||||
row[2].text = stat["languages"]
|
||||
hinweis = doc.add_paragraph()
|
||||
hinweis_run = hinweis.add_run(_source_stats_note(all_sources, articles))
|
||||
hinweis_run.font.size = Pt(8)
|
||||
hinweis_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
||||
|
||||
# --- Quellenverzeichnis ---
|
||||
# Fehlte in der Word-Ausgabe komplett: Der Text verwies auf [1], [2],
|
||||
# ohne dass die Datei die Nummern irgendwo aufloeste. Die Nummer ist
|
||||
# die aus dem Lagebild (nr), NICHT die laufende Zeilennummer.
|
||||
if all_sources:
|
||||
doc.add_heading("Quellenverzeichnis", level=1)
|
||||
table = doc.add_table(rows=1, cols=3)
|
||||
table.style = 'Table Grid'
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
hdr = table.rows[0].cells
|
||||
hdr[0].text = "#"
|
||||
hdr[1].text = "Quelle"
|
||||
hdr[2].text = "URL"
|
||||
for cell in hdr:
|
||||
for p in cell.paragraphs:
|
||||
p.runs[0].font.bold = True
|
||||
p.runs[0].font.size = Pt(9)
|
||||
for src in all_sources:
|
||||
row = table.add_row().cells
|
||||
row[0].text = str(src.get("nr", ""))
|
||||
row[1].text = src.get("name") or src.get("title") or ""
|
||||
url = src.get("url") or ""
|
||||
if url:
|
||||
zelle = row[2].paragraphs[0]
|
||||
_add_docx_hyperlink(zelle, url, url)
|
||||
for cell in row:
|
||||
for p in cell.paragraphs:
|
||||
for run in p.runs:
|
||||
run.font.size = Pt(8)
|
||||
|
||||
if "timeline" in sections:
|
||||
# --- Artikelverzeichnis ---
|
||||
|
||||
@@ -64,6 +64,9 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
|
||||
/* Tabellen */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
|
||||
.quellen-table { table-layout: fixed; font-size: 8pt; }
|
||||
/* Ein Quelleneintrag darf nicht ueber die Seitengrenze zerfallen: sonst steht
|
||||
die Nummer am Fuss der einen und die Adresse am Kopf der naechsten Seite. */
|
||||
tr { page-break-inside: avoid; }
|
||||
th { background: #0a1832; color: #fff; text-align: left; padding: 6px 10px; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; }
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
@@ -159,20 +162,21 @@ tr:nth-child(even) { background: #f8f9fa; }
|
||||
{% if source_stats %}
|
||||
<h3>Quellenstatistik</h3>
|
||||
<table>
|
||||
<thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead>
|
||||
<thead><tr><th>Quelle</th><th>Belege</th><th>Sprache</th></tr></thead>
|
||||
<tbody>
|
||||
{% for stat in source_stats %}
|
||||
<tr><td>{{ stat.name }}</td><td>{{ stat.count }}</td><td>{{ stat.languages }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if source_stats_note %}<p style="font-size:8pt;color:#666;margin-top:4px">{{ source_stats_note }}</p>{% endif %}
|
||||
{% endif %}
|
||||
<h3>Quellen</h3>
|
||||
<table class="quellen-table">
|
||||
<thead><tr><th style="width:30px">#</th><th style="width:120px">Quelle</th><th>URL</th></tr></thead>
|
||||
<tbody>
|
||||
{% for src in sources %}
|
||||
<tr><td style="font-size:8pt">{{ loop.index }}</td><td style="font-size:8pt">{{ src.name or src.title or '' }}</td><td style="font-size:7pt;color:#666;word-break:break-all;line-height:1.3">{{ src.url or '' }}</td></tr>
|
||||
<tr><td style="font-size:8pt">{{ src.nr if src.nr is not none else loop.index }}</td><td style="font-size:8pt">{{ src.name or src.title or '' }}</td><td style="font-size:7pt;color:#666;word-break:break-all;line-height:1.3">{{ src.url or '' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
||||
INCIDENT_UPDATE_COLUMNS = {
|
||||
"title", "description", "type", "status", "refresh_mode",
|
||||
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "include_x", "visibility",
|
||||
"ai_backend",
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ async def list_incidents(
|
||||
query = (
|
||||
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
|
||||
"refresh_start_time, retention_days, visibility, "
|
||||
"international_sources, include_telegram, include_x, created_by, created_at, updated_at, "
|
||||
"international_sources, include_telegram, include_x, ai_backend, created_by, created_at, updated_at, "
|
||||
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
|
||||
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
|
||||
)
|
||||
@@ -121,8 +122,8 @@ async def create_incident(
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
|
||||
refresh_start_time, retention_days, international_sources, include_telegram, include_x, visibility,
|
||||
tenant_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
ai_backend, tenant_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.title,
|
||||
data.description,
|
||||
@@ -135,6 +136,7 @@ async def create_incident(
|
||||
1 if data.include_telegram else 0,
|
||||
1 if data.include_x else 0,
|
||||
data.visibility,
|
||||
data.ai_backend or None,
|
||||
tenant_id,
|
||||
current_user["id"],
|
||||
now,
|
||||
@@ -385,6 +387,9 @@ async def update_incident(
|
||||
continue
|
||||
if field in ("international_sources", "include_telegram", "include_x"):
|
||||
updates[field] = 1 if value else 0
|
||||
elif field == "ai_backend":
|
||||
# Leerstring = "Vorgabe der Organisation", wird als NULL gespeichert.
|
||||
updates[field] = value or None
|
||||
else:
|
||||
updates[field] = value
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger("osint.fact_consolidation")
|
||||
|
||||
STATUS_PRIORITY = {
|
||||
"confirmed": 5, "established": 5,
|
||||
"contradicted": 4, "disputed": 4,
|
||||
"contradicted": 4, "disputed": 4, "false": 4,
|
||||
"unconfirmed": 3, "unverified": 3,
|
||||
"developing": 1,
|
||||
}
|
||||
|
||||
187
src/services/media_registry.py
Normale Datei
187
src/services/media_registry.py
Normale Datei
@@ -0,0 +1,187 @@
|
||||
"""Kanonische Medien-Identitaet je Domain.
|
||||
|
||||
Hintergrund: Quellennamen kommen aus drei Richtungen in den Bestand, aus dem
|
||||
Feed-Namen (RSS), aus dem Hostnamen (staan-Treffer im EU-Weg) und aus der freien
|
||||
Benennung durch das Modell in der Abschlussauswahl. Dadurch fuehrt derselbe
|
||||
Verlag mehrere Identitaeten, etwa "Guardian UK", "Guardian World" und
|
||||
"The Guardian" oder "tagesschau" und "tagesschau.de". Jede Aussage ueber
|
||||
Quellenvielfalt oder Belegtiefe wird damit falsch, weil dieselbe Redaktion
|
||||
mehrfach gezaehlt wird.
|
||||
|
||||
Dieses Modul bestimmt die Identitaet ueber die Domain, nicht ueber den Namen.
|
||||
Der Anzeigename wird datengetrieben gewaehlt: der haeufigste Name, der fuer
|
||||
diese Domain vorkommt. Die Tabelle unten deckt nur die Faelle ab, in denen gar
|
||||
kein brauchbarer Name vorliegt oder der Hostname als Name durchschlaegt.
|
||||
"""
|
||||
from collections import Counter
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Zusammengesetzte Endungen, bei denen die registrierbare Domain drei Labels
|
||||
# hat (bbc.co.uk statt co.uk).
|
||||
_MEHRTEILIGE_ENDUNGEN = frozenset({
|
||||
"co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk",
|
||||
"com.au", "net.au", "org.au", "gov.au",
|
||||
"co.jp", "or.jp", "ne.jp", "go.jp",
|
||||
"com.br", "com.mx", "com.ar", "com.tr", "com.cn", "com.hk",
|
||||
"co.za", "co.nz", "co.kr", "co.in", "co.il",
|
||||
"or.at", "co.at", "gv.at",
|
||||
})
|
||||
|
||||
# Praefixe, die dieselbe Redaktion nur anders ausliefern.
|
||||
_HOST_PRAEFIXE = ("www.", "m.", "amp.", "rss.", "feeds.", "news.", "english.", "de.", "en.")
|
||||
|
||||
# Anzeigename, wenn kein brauchbarer Name mitgeliefert wurde. Bewusst kurz
|
||||
# gehalten, die Liste ist keine Pflegepflicht: unbekannte Domains bekommen
|
||||
# ihren ersten Domain-Bestandteil in Grossschreibung.
|
||||
_NAMEN_JE_DOMAIN = {
|
||||
"tagesschau.de": "tagesschau",
|
||||
"zdfheute.de": "ZDF heute",
|
||||
"zdf.de": "ZDF heute",
|
||||
"faz.net": "FAZ",
|
||||
"sueddeutsche.de": "Süddeutsche Zeitung",
|
||||
"spiegel.de": "Spiegel",
|
||||
"zeit.de": "Zeit",
|
||||
"welt.de": "Welt",
|
||||
"n-tv.de": "n-tv",
|
||||
"dw.com": "Deutsche Welle",
|
||||
"deutschlandfunk.de": "Deutschlandfunk",
|
||||
"bundesregierung.de": "Bundesregierung",
|
||||
"nzz.ch": "NZZ",
|
||||
"srf.ch": "SRF",
|
||||
"kurier.at": "Kurier",
|
||||
"diepresse.com": "Die Presse",
|
||||
"theguardian.com": "The Guardian",
|
||||
"nytimes.com": "New York Times",
|
||||
"ft.com": "Financial Times",
|
||||
"bbc.co.uk": "BBC",
|
||||
"bbc.com": "BBC",
|
||||
"aljazeera.com": "Al Jazeera",
|
||||
"france24.com": "France24",
|
||||
"reuters.com": "Reuters",
|
||||
"apnews.com": "AP News",
|
||||
"elpais.com": "El País",
|
||||
"elmundo.es": "El Mundo",
|
||||
"abc.es": "ABC",
|
||||
"infobae.com": "Infobae",
|
||||
"politico.eu": "Politico Europe",
|
||||
"euronews.com": "Euronews",
|
||||
}
|
||||
|
||||
# Weiterleitungs- und Sammelportale. Sie sind kein eigenstaendiges Medium und
|
||||
# duerfen in keiner Vielfaltsstatistik als Redaktion durchgehen.
|
||||
AGGREGATOR_DOMAINS = frozenset({
|
||||
"news.google.com", "google.com", "msn.com", "headtopics.com",
|
||||
"newsbreak.com", "yahoo.com", "flipboard.com", "smartnews.com",
|
||||
"reutersconnect.com", "pressreader.com", "onvista.de", "finanznachrichten.de",
|
||||
"boersennews.de", "aol.com",
|
||||
})
|
||||
|
||||
|
||||
def registrable_domain(url: str) -> str:
|
||||
"""Registrierbare Domain einer URL, klein geschrieben.
|
||||
|
||||
'https://www.theguardian.com/world/...' -> 'theguardian.com'
|
||||
'https://www.bbc.co.uk/news/...' -> 'bbc.co.uk'
|
||||
Leere oder unparsebare Eingaben ergeben einen leeren String.
|
||||
"""
|
||||
host = ""
|
||||
text = (url or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
host = (urlparse(text if "//" in text else "//" + text).hostname or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
if not host:
|
||||
return ""
|
||||
for praefix in _HOST_PRAEFIXE:
|
||||
if host.startswith(praefix) and host.count(".") >= 2:
|
||||
host = host[len(praefix):]
|
||||
teile = host.split(".")
|
||||
if len(teile) <= 2:
|
||||
return host
|
||||
if ".".join(teile[-2:]) in _MEHRTEILIGE_ENDUNGEN:
|
||||
return ".".join(teile[-3:])
|
||||
return ".".join(teile[-2:])
|
||||
|
||||
|
||||
def ist_aggregator(url: str) -> bool:
|
||||
"""True, wenn die URL auf ein Weiterleitungs- oder Sammelportal zeigt."""
|
||||
return registrable_domain(url) in AGGREGATOR_DOMAINS
|
||||
|
||||
|
||||
def name_aus_domain(domain: str) -> str:
|
||||
"""Anzeigename allein aus der Domain, wenn kein Name vorliegt."""
|
||||
if not domain:
|
||||
return "Unbekannt"
|
||||
if domain in _NAMEN_JE_DOMAIN:
|
||||
return _NAMEN_JE_DOMAIN[domain]
|
||||
erstes = domain.split(".")[0]
|
||||
return erstes if any(c.isupper() for c in erstes) else erstes.capitalize()
|
||||
|
||||
|
||||
def _ist_hostname_als_name(name: str, domain: str) -> bool:
|
||||
"""True, wenn der Name nur der Hostname ist ('www.france24.com')."""
|
||||
schlicht = (name or "").strip().lower()
|
||||
return bool(schlicht) and (schlicht == domain or schlicht.endswith(domain))
|
||||
|
||||
|
||||
def namen_je_domain(eintraege: list[tuple[str, str]]) -> dict[str, str]:
|
||||
"""Waehlt je Domain einen Anzeigenamen aus allen gelieferten Varianten.
|
||||
|
||||
eintraege ist eine Liste aus (url, name). Gewinner ist der haeufigste Name,
|
||||
bei Gleichstand der kuerzeste. Hostnamen und leere Namen zaehlen nicht mit,
|
||||
fuer sie greift die Tabelle beziehungsweise die Domain selbst.
|
||||
"""
|
||||
kandidaten: dict[str, Counter] = {}
|
||||
for url, name in eintraege:
|
||||
domain = registrable_domain(url)
|
||||
if not domain:
|
||||
continue
|
||||
zaehler = kandidaten.setdefault(domain, Counter())
|
||||
sauber = (name or "").strip()
|
||||
if sauber and not _ist_hostname_als_name(sauber, domain):
|
||||
zaehler[sauber] += 1
|
||||
|
||||
ergebnis: dict[str, str] = {}
|
||||
for domain, zaehler in kandidaten.items():
|
||||
# Der kuratierte Name hat Vorrang. Sonst gewinnt der haeufigste
|
||||
# gelieferte Name, und das ist in der Praxis der Feed-Name: der
|
||||
# Guardian erschien im Bericht als "Guardian World", die New York
|
||||
# Times als "NYT Top Stories". Ein Feed ist aber kein Medium.
|
||||
if domain in _NAMEN_JE_DOMAIN:
|
||||
ergebnis[domain] = _NAMEN_JE_DOMAIN[domain]
|
||||
continue
|
||||
if not zaehler:
|
||||
ergebnis[domain] = name_aus_domain(domain)
|
||||
continue
|
||||
hoechste = max(zaehler.values())
|
||||
beste = sorted((n for n, c in zaehler.items() if c == hoechste), key=lambda n: (len(n), n))
|
||||
ergebnis[domain] = beste[0]
|
||||
return ergebnis
|
||||
|
||||
|
||||
def sprache_aus_url(url: str) -> str:
|
||||
"""Sprachkennung, die sich eindeutig aus der Adresse ergibt, sonst ''.
|
||||
|
||||
Deckt die Faelle ab, in denen das Modell die Sprache falsch angibt, etwa
|
||||
'english.elpais.com' als DE. Nur eindeutige Muster, keine Raterei.
|
||||
"""
|
||||
text = (url or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
zerlegt = urlparse(text if "//" in text else "//" + text)
|
||||
except ValueError:
|
||||
return ""
|
||||
host = zerlegt.hostname or ""
|
||||
pfad = zerlegt.path or ""
|
||||
if host.startswith("english.") or pfad.startswith("/en/") or "/english/" in pfad:
|
||||
return "EN"
|
||||
if host.startswith("de.") or pfad.startswith("/de/"):
|
||||
return "DE"
|
||||
if host.startswith("fr.") or pfad.startswith("/fr/"):
|
||||
return "FR"
|
||||
if host.startswith("es.") or pfad.startswith("/es/"):
|
||||
return "ES"
|
||||
return ""
|
||||
@@ -20,11 +20,39 @@ logger = logging.getLogger("osint.post_refresh_qc")
|
||||
|
||||
STATUS_PRIORITY = {
|
||||
"confirmed": 5, "established": 5,
|
||||
"contradicted": 4, "disputed": 4,
|
||||
"contradicted": 4, "disputed": 4, "false": 4,
|
||||
"unconfirmed": 3, "unverified": 3,
|
||||
"developing": 1,
|
||||
}
|
||||
|
||||
# Schutz gegen Fehlurteile beim Zusammenfassen von Duplikaten. Das Clustering
|
||||
# entscheidet ein schnelles Modell, das gelegentlich inhaltlich verschiedene
|
||||
# Fakten in eine Gruppe wirft, etwa wenn viele Behauptungen gleich beginnen.
|
||||
# Zwei Sicherungen greifen. Eine Gruppe, die einen zu grossen Anteil aller
|
||||
# Fakten umfasst, wird komplett verworfen. Und jeder einzelne Loeschkandidat
|
||||
# muss dem behaltenen Fakt auch rechnerisch aehnlich genug sein.
|
||||
DEDUP_MAX_CLUSTER_ANTEIL = float(os.environ.get("QC_DEDUP_MAX_CLUSTER_ANTEIL", "0.4"))
|
||||
DEDUP_MIN_AEHNLICHKEIT = float(os.environ.get("QC_DEDUP_MIN_AEHNLICHKEIT", "0.55"))
|
||||
|
||||
|
||||
def _aehnlichkeit(claim_a: str, claim_b: str) -> float:
|
||||
"""Rechnerische Aehnlichkeit zweier Behauptungen zwischen 0 und 1.
|
||||
|
||||
Gleiche Gewichtung wie im Vorfilter, damit beide Stufen dasselbe Mass
|
||||
verwenden.
|
||||
"""
|
||||
from agents.factchecker import normalize_claim, _keyword_set
|
||||
|
||||
norm_a = normalize_claim(claim_a or "")
|
||||
norm_b = normalize_claim(claim_b or "")
|
||||
if not norm_a or not norm_b:
|
||||
return 0.0
|
||||
kw_a = _keyword_set(claim_a or "")
|
||||
kw_b = _keyword_set(claim_b or "")
|
||||
kw_union = kw_a | kw_b
|
||||
jaccard = len(kw_a & kw_b) / len(kw_union) if kw_union else 0.0
|
||||
return 0.7 * SequenceMatcher(None, norm_a, norm_b).ratio() + 0.3 * jaccard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Faktencheck-Duplikate
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -177,11 +205,23 @@ async def check_fact_duplicates(db, incident_id: int, incident_title: str) -> in
|
||||
facts_by_id = {f["id"]: f for f in all_facts}
|
||||
ids_to_delete = set()
|
||||
|
||||
max_cluster = max(2, int(len(all_facts) * DEDUP_MAX_CLUSTER_ANTEIL))
|
||||
|
||||
for cluster_ids in all_clusters:
|
||||
valid_ids = [cid for cid in cluster_ids if cid in facts_by_id]
|
||||
if len(valid_ids) <= 1:
|
||||
continue
|
||||
|
||||
# Sicherung 1: Eine Gruppe, die einen zu grossen Teil aller Fakten
|
||||
# umfasst, ist fast immer ein Fehlurteil des Clusterings.
|
||||
if len(valid_ids) > max_cluster:
|
||||
logger.warning(
|
||||
"QC Duplikat: Gruppe mit %d von %d Fakten verworfen, das ist "
|
||||
"unplausibel (Grenze %d)",
|
||||
len(valid_ids), len(all_facts), max_cluster,
|
||||
)
|
||||
continue
|
||||
|
||||
cluster_facts = [facts_by_id[cid] for cid in valid_ids]
|
||||
best = max(cluster_facts, key=lambda f: (
|
||||
STATUS_PRIORITY.get(f["status"], 0),
|
||||
@@ -190,12 +230,23 @@ async def check_fact_duplicates(db, incident_id: int, incident_title: str) -> in
|
||||
))
|
||||
|
||||
for fact in cluster_facts:
|
||||
if fact["id"] != best["id"]:
|
||||
ids_to_delete.add(fact["id"])
|
||||
logger.info(
|
||||
"QC Duplikat: ID %d entfernt, behalte ID %d ('%s')",
|
||||
fact["id"], best["id"], best["claim"][:60],
|
||||
if fact["id"] == best["id"]:
|
||||
continue
|
||||
# Sicherung 2: Nur loeschen, wenn die beiden Behauptungen auch
|
||||
# rechnerisch nah beieinander liegen.
|
||||
naehe = _aehnlichkeit(fact["claim"], best["claim"])
|
||||
if naehe < DEDUP_MIN_AEHNLICHKEIT:
|
||||
logger.warning(
|
||||
"QC Duplikat: ID %d behalten, Aehnlichkeit zu ID %d nur %.2f "
|
||||
"('%s')",
|
||||
fact["id"], best["id"], naehe, fact["claim"][:60],
|
||||
)
|
||||
continue
|
||||
ids_to_delete.add(fact["id"])
|
||||
logger.info(
|
||||
"QC Duplikat: ID %d entfernt (Aehnlichkeit %.2f), behalte ID %d ('%s')",
|
||||
fact["id"], naehe, best["id"], best["claim"][:60],
|
||||
)
|
||||
|
||||
if ids_to_delete:
|
||||
placeholders = ",".join("?" * len(ids_to_delete))
|
||||
|
||||
131
src/services/staan_client.py
Normale Datei
131
src/services/staan_client.py
Normale Datei
@@ -0,0 +1,131 @@
|
||||
"""staan.ai Such-Client. Europäische Websuche für den EU-Modellweg (Phase 2).
|
||||
|
||||
Ein Baustein für Suche UND Volltextabruf. Der Parameter full_content="markdown"
|
||||
liefert die kompletten Seiteninhalte der Treffer gleich mit, damit braucht die
|
||||
EU-Recherche-Schleife keinen eigenen Abruf einzelner Seiten. Die Verarbeitung
|
||||
bleibt vollständig beim europäischen Anbieter (European Search Perspective,
|
||||
gehostet bei OVHcloud).
|
||||
|
||||
Befund aus Phase 0. Die API liefert praktisch nie ein Veröffentlichungsdatum
|
||||
und kennt keinen Zeitraum-Filter. Die Aktualität sichern deshalb die
|
||||
Query-Formulierung (macht die Schleife) und die Datumsextraktion aus den
|
||||
Inhalten (macht das Modell). Der Domain-Ausschlussfilter ist Pflichtbestandteil
|
||||
jeder Anfrage, ohne ihn bestehen die Trefferlisten spürbar aus YouTube,
|
||||
Wikipedia und Social-Media-Seiten.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (
|
||||
STAAN_API_KEY,
|
||||
STAAN_BASE_URL,
|
||||
STAAN_EXCLUDE_DOMAINS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("osint.staan_client")
|
||||
|
||||
# Von staan offiziell unterstützte Märkte. Andere Werte fallen auf en-us zurück,
|
||||
# der Index liefert auch dann fremdsprachige Treffer (in Phase 0 belegt für
|
||||
# Russisch, Ukrainisch, Arabisch, Farsi, Hebräisch und Japanisch).
|
||||
ALLOWED_MARKETS = {"de-de", "en-us", "fr-fr"}
|
||||
|
||||
# staan akzeptiert maximal 10 Ausschluss-Domains je Anfrage. Die Pflichtliste
|
||||
# aus config belegt die ersten Plätze, Nutzer-Ausschlüsse füllen bis 10 auf.
|
||||
_MAX_EXCLUDE_DOMAINS = 10
|
||||
|
||||
|
||||
class StaanError(RuntimeError):
|
||||
"""Fehler der staan-API (Status, Auth, Netz)."""
|
||||
|
||||
|
||||
def market_for_language(lang_iso: str | None) -> str:
|
||||
"""ISO-Sprachcode auf den passendsten staan-Markt abbilden."""
|
||||
mapping = {"de": "de-de", "en": "en-us", "fr": "fr-fr"}
|
||||
return mapping.get((lang_iso or "").lower().strip(), "en-us")
|
||||
|
||||
|
||||
async def staan_search(
|
||||
q: str,
|
||||
market: str = "en-us",
|
||||
extra_snippets: bool = True,
|
||||
max_snippets: int = 5,
|
||||
full_content: bool = False,
|
||||
extra_exclude: list[str] | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> list[dict]:
|
||||
"""Eine Suchanfrage gegen staan. Gibt normalisierte Treffer zurück.
|
||||
|
||||
Rückgabe je Treffer: title, url, hostname, snippet, extra_snippets (Liste
|
||||
von Text-Chunks), full_text (Markdown, gekappt), published_date (fast
|
||||
immer None, siehe Modul-Docstring).
|
||||
"""
|
||||
if not STAAN_API_KEY:
|
||||
raise StaanError("STAAN_API_KEY ist nicht gesetzt, EU-Suche nicht nutzbar")
|
||||
|
||||
if market not in ALLOWED_MARKETS:
|
||||
market = "en-us"
|
||||
|
||||
exclude = list(STAAN_EXCLUDE_DOMAINS)
|
||||
for d in extra_exclude or []:
|
||||
d = (d or "").strip().lower()
|
||||
if not d or d in exclude:
|
||||
continue
|
||||
if len(exclude) >= _MAX_EXCLUDE_DOMAINS:
|
||||
break
|
||||
exclude.append(d)
|
||||
|
||||
body: dict = {"q": (q or "").strip()[:400], "market": market, "exclude_domains": exclude}
|
||||
if extra_snippets:
|
||||
body["extra_snippets"] = True
|
||||
body["max_snippets"] = max(1, min(int(max_snippets), 10))
|
||||
if full_content:
|
||||
body["full_content"] = "markdown"
|
||||
|
||||
headers = {"Authorization": f"Bearer {STAAN_API_KEY}", "Content-Type": "application/json"}
|
||||
|
||||
data = None
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
resp = await client.post(f"{STAAN_BASE_URL}/search/web", json=body, headers=headers)
|
||||
except httpx.TimeoutException as e:
|
||||
raise StaanError(f"staan Timeout nach {timeout}s ({e})") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise StaanError(f"staan Netzwerkfehler ({type(e).__name__}: {e})") from e
|
||||
if resp.status_code == 429 and attempt == 1:
|
||||
# Rate-Limit (20 req/s), kurz warten und einmal wiederholen
|
||||
await asyncio.sleep(1.5)
|
||||
continue
|
||||
if resp.status_code not in (200, 201):
|
||||
# GET antwortet mit 200, POST mit 201 (Created)
|
||||
raise StaanError(f"staan HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
raise StaanError(f"staan lieferte kein JSON ({e})") from e
|
||||
break
|
||||
|
||||
results: list[dict] = []
|
||||
for r in (data.get("web") or {}).get("results", []):
|
||||
fc = r.get("full_content") or {}
|
||||
results.append({
|
||||
"title": (r.get("title") or "").strip(),
|
||||
"url": (r.get("url") or "").strip(),
|
||||
"hostname": (r.get("hostname") or "").strip(),
|
||||
"snippet": (r.get("snippet") or "").strip(),
|
||||
"extra_snippets": [
|
||||
(c.get("chunk") or "").strip()
|
||||
for c in (r.get("extra_snippets") or [])
|
||||
if (c.get("chunk") or "").strip()
|
||||
],
|
||||
"full_text": ((fc.get("text") or "").strip())[:6000],
|
||||
"published_date": r.get("published_date"),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"staan [%s] %r -> %d Treffer%s",
|
||||
market, body["q"][:60], len(results), " mit Volltexten" if full_content else "",
|
||||
)
|
||||
return results
|
||||
@@ -383,6 +383,10 @@
|
||||
.btn-bulk {
|
||||
flex: 1;
|
||||
min-width: 84px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -394,6 +398,7 @@
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
}
|
||||
.btn-bulk svg { flex-shrink: 0; }
|
||||
.btn-bulk:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-bulk[hidden] { display: none; }
|
||||
.btn-bulk.danger { color: var(--danger, #EF4444); }
|
||||
@@ -1009,180 +1014,6 @@
|
||||
es neu, die Zeile darunter meldet den Datenstand (aktuell / veraltet / laeuft). */
|
||||
@keyframes st-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Kompletter Lauf: orchestriert alles, daher als Primaerknopf ueber den Karten */
|
||||
.run-all {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-title);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, opacity .15s;
|
||||
}
|
||||
.run-all:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.run-all:disabled { opacity: .45; cursor: not-allowed; }
|
||||
/* display:flex wuerde das hidden-Attribut ueberstimmen - explizit ausblenden */
|
||||
.run-all[hidden] { display: none; }
|
||||
.run-all .ra-icon { display: inline-flex; }
|
||||
.run-all .ra-state { display: none; }
|
||||
.run-all.running .ra-icon { display: none; }
|
||||
.run-all.running .ra-state {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-weight: 400; font-size: 11.5px;
|
||||
}
|
||||
.run-all.running .ra-state::before {
|
||||
content: ''; width: 11px; height: 11px; flex-shrink: 0;
|
||||
border: 2px solid currentColor; border-right-color: transparent;
|
||||
border-radius: 50%; animation: st-spin .8s linear infinite;
|
||||
}
|
||||
|
||||
/* Die Karte selbst */
|
||||
.art-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.art-card:hover { border-color: var(--accent); }
|
||||
.art-card:has(.studio-menu-item.active) { border-color: var(--accent); }
|
||||
.art-card.busy { border-color: var(--accent); }
|
||||
.art-card-soon { opacity: .6; }
|
||||
.art-row { display: flex; align-items: stretch; }
|
||||
|
||||
/* Kartenkopf: uebernimmt die Rolle des alten Menue-Eintrags, aber ohne eigenen Rahmen */
|
||||
.studio-menu .art-head {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px var(--sp-lg);
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.studio-menu .art-head:hover { background: var(--bg-hover); }
|
||||
.studio-menu .art-head.active { background: var(--tint-accent-faint); }
|
||||
.studio-menu .art-head-static { cursor: default; }
|
||||
.studio-menu .art-head-static:hover { background: none; }
|
||||
.art-head .mi-icon { flex-shrink: 0; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
|
||||
.art-head .mi-text { flex: 1; min-width: 0; }
|
||||
.art-head .mi-title {
|
||||
display: block;
|
||||
font-family: var(--font-title);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.art-head .mi-meta { display: block; margin-top: 1px; font-size: 10.5px; color: var(--text-disabled); }
|
||||
|
||||
/* Erzeuger-Aktion (rechnet das Ergebnis neu) */
|
||||
.art-run {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.art-run:hover:not(:disabled) { color: var(--accent); background: var(--bg-hover); }
|
||||
.art-run:disabled { opacity: .35; cursor: not-allowed; }
|
||||
|
||||
/* Datenstand-Zeile */
|
||||
.art-note {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: var(--sp-md);
|
||||
padding: 7px var(--sp-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.art-note.show { display: flex; }
|
||||
.art-note.fresh { background: var(--tint-success); color: var(--success); }
|
||||
.art-note.stale { background: var(--tint-warning); color: var(--warning); }
|
||||
.art-note.blocked { background: var(--bg-elevated); color: var(--text-disabled); }
|
||||
.art-note.running { background: var(--tint-accent); color: var(--accent); }
|
||||
.art-note .an-spin {
|
||||
width: 11px; height: 11px; flex-shrink: 0;
|
||||
border: 2px solid currentColor; border-right-color: transparent;
|
||||
border-radius: 50%; animation: st-spin .8s linear infinite;
|
||||
}
|
||||
.art-note button {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
padding: 3px 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
border: 1px solid currentColor;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.art-note button:hover { background: var(--tint-hover-subtle); }
|
||||
|
||||
/* Unter-Ansichten: gehoeren zum selben Erzeuger (Analyse) */
|
||||
.art-subs { border-top: 1px solid var(--border); }
|
||||
.studio-menu .art-sub {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px var(--sp-lg);
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.studio-menu .art-sub:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.studio-menu .art-sub.active { background: var(--tint-accent-faint); color: var(--accent); }
|
||||
.art-sub .mi-icon { flex-shrink: 0; color: var(--text-disabled); display: inline-flex; }
|
||||
.art-sub.active .mi-icon { color: var(--accent); }
|
||||
.art-sub .mi-title {
|
||||
flex: 1; min-width: 0;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.art-sub .mi-meta { flex-shrink: 0; font-size: 10px; color: var(--text-disabled); }
|
||||
|
||||
/* Export: Ausgabe-Aktion, kein Artefakt -> Fusszeile */
|
||||
.art-foot { margin-top: var(--sp-lg); padding-top: var(--sp-lg); border-top: 1px solid var(--border); }
|
||||
.studio-menu .art-export { justify-content: center; gap: 8px; margin-bottom: 0; padding: 8px; font-size: 12px; }
|
||||
.art-export .mi-title { font-family: var(--font-body); font-weight: 600; font-size: 12px; flex: 0 0 auto; }
|
||||
.art-export .mi-icon { color: var(--accent); display: inline-flex; }
|
||||
|
||||
/* "Sammeln" in der Quellenspalte (sein Ergebnis landet dort) */
|
||||
.collect-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.collect-btn.running .cb-label { display: none; }
|
||||
.collect-btn.running::after {
|
||||
content: 'Sammelt …';
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
|
||||
/* Karten-Container fuellt den Karte-Tab */
|
||||
#map-container {
|
||||
width: 100%;
|
||||
@@ -1372,3 +1203,330 @@
|
||||
.chat-offer-focus{font-size:12px;opacity:.7;margin-bottom:8px;}
|
||||
.chat-offer-btn{cursor:pointer;}
|
||||
.chat-offer-btn:disabled{opacity:.6;cursor:default;}
|
||||
|
||||
/* === Einstellungen des Falls ========================================== */
|
||||
/* Ersetzt das Bearbeiten-Modal der klassischen Ansicht. Eine Spalte mit
|
||||
begrenzter Breite, damit die Felder auf breiten Schirmen nicht auseinander
|
||||
laufen. */
|
||||
.settings-panel {
|
||||
max-width: 560px;
|
||||
padding: 4px 2px 16px;
|
||||
}
|
||||
|
||||
.settings-panel .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-panel label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-panel .toggle-label {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-panel input[type="text"],
|
||||
.settings-panel input[type="number"],
|
||||
.settings-panel input[type="time"],
|
||||
.settings-panel select,
|
||||
.settings-panel textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-panel textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Hinweis auf einen gerade laufenden Schritt */
|
||||
.settings-note {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-note[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Warnung, sobald der KI-Weg vom gespeicherten Stand abweicht */
|
||||
.settings-warn {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-left: 2px solid var(--warning);
|
||||
background: var(--tint-warning);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-warn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Der Zustandstext schiebt die Knoepfe nach rechts */
|
||||
.settings-state {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settings-state.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.settings-state.fehler {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Kennzeichen des KI-Wegs in der Fallliste. Schrumpft nicht, der Titel
|
||||
kuerzt sich stattdessen. */
|
||||
.case-ai-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.case-ai-tag.ai-eu {
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.case-ai-tag.ai-anthropic {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* === Der Lauf (Spalte 3) ============================================== */
|
||||
/* Genau ein Startpunkt. Die vier Schritte darunter sind Anzeige, sie starten
|
||||
nichts. Frueher standen hier Artefakt-Karten, in denen ein Klick links das
|
||||
Ergebnis oeffnete und einer rechts es neu rechnete. Das lag zu nah
|
||||
beieinander und kostete im falschen Fall Geld. */
|
||||
|
||||
.lauf-stand {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* Vollstaendig umlaufender Rahmen, kein einseitiger Streifen */
|
||||
.lauf-stand.betont {
|
||||
border: 2px solid var(--sidebar-active, #C8A851);
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.ls-haupt {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ls-haupt.warn { color: var(--warning); }
|
||||
|
||||
.ls-neben {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ls-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--sidebar-active, #C8A851);
|
||||
background: var(--sidebar-active, #C8A851);
|
||||
color: #1A1A1A;
|
||||
font-family: var(--font-title, inherit);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter .15s, opacity .15s;
|
||||
}
|
||||
|
||||
.ls-btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
|
||||
.ls-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.ls-btn.abbrechen {
|
||||
background: transparent;
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.ls-icon { display: inline-flex; flex-shrink: 0; }
|
||||
|
||||
.ls-hinweis {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ls-hinweis[hidden] { display: none; }
|
||||
|
||||
/* --- Die Kette --- */
|
||||
.lauf-kette {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 6px 8px;
|
||||
}
|
||||
|
||||
.kn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.kn-fertig { background: var(--success); border-color: var(--success); }
|
||||
.kn-veraltet { background: var(--warning); border-color: var(--warning); }
|
||||
.kn-laeuft {
|
||||
background: var(--sidebar-active, #C8A851);
|
||||
border-color: var(--sidebar-active, #C8A851);
|
||||
animation: kn-puls 1.4s ease-in-out infinite;
|
||||
}
|
||||
.kn-gesperrt { opacity: .45; }
|
||||
|
||||
.kn-linie { flex: 1; height: 2px; background: var(--border); }
|
||||
.kn-linie.aktiv { background: var(--success); }
|
||||
|
||||
@keyframes kn-puls { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kn-laeuft { animation: none; }
|
||||
}
|
||||
|
||||
.lauf-fortschritt {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* --- Die Schritte --- */
|
||||
.lauf-schritte { margin-bottom: 4px; }
|
||||
|
||||
.schritt {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schritt-laeuft, .schritt-veraltet {
|
||||
border: 2px solid var(--sidebar-active, #C8A851);
|
||||
}
|
||||
|
||||
.schritt-gesperrt { opacity: .55; }
|
||||
|
||||
.schritt-kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schritt-kopf:hover { background: var(--tint-hover-subtle, rgba(255,255,255,.03)); }
|
||||
|
||||
.schritt-nr {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.schritt-fertig .schritt-nr { background: var(--tint-success); color: var(--success); }
|
||||
.schritt-veraltet .schritt-nr { background: var(--tint-warning); color: var(--warning); }
|
||||
.schritt-laeuft .schritt-nr { background: var(--tint-accent); color: var(--sidebar-active, #C8A851); }
|
||||
|
||||
.schritt-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.schritt-name { font-size: 12px; }
|
||||
.schritt-stand { font-size: 11px; color: var(--text-tertiary); }
|
||||
.schritt-fertig .schritt-stand { color: var(--success); }
|
||||
.schritt-veraltet .schritt-stand { color: var(--warning); }
|
||||
|
||||
.schritt-pfeil {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
transition: transform .18s;
|
||||
}
|
||||
|
||||
.schritt.offen .schritt-pfeil { transform: rotate(180deg); }
|
||||
|
||||
.schritt-mehr {
|
||||
display: none;
|
||||
padding: 0 10px 10px 40px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schritt.offen .schritt-mehr { display: block; }
|
||||
|
||||
/* --- Bereichstrenner im Menue --- */
|
||||
.menu-bereich {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 20px 0 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.menu-bereich::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Untergeordnete Ansicht, gehoert zum Eintrag darueber */
|
||||
.studio-menu-item.mi-unter {
|
||||
padding-left: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.studio-menu-item.mi-unter .mi-title { font-size: 12px; }
|
||||
|
||||
@@ -1199,6 +1199,12 @@ a.dev-source-pill:hover {
|
||||
}
|
||||
|
||||
.factcheck-icon.contradicted {
|
||||
background: var(--tint-warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Widerlegt: eigener Status seit der Trennung von Widerspruch und Widerlegung */
|
||||
.factcheck-icon.false {
|
||||
background: var(--tint-error);
|
||||
color: var(--error);
|
||||
}
|
||||
@@ -1914,6 +1920,11 @@ a.dev-source-pill:hover {
|
||||
}
|
||||
|
||||
.badge-contradicted {
|
||||
background: var(--tint-warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-false {
|
||||
background: var(--tint-error);
|
||||
color: var(--error);
|
||||
}
|
||||
@@ -2210,7 +2221,7 @@ body.first-refresh-blocking .header {
|
||||
.progress-popup-body { padding: 16px 20px; }
|
||||
.progress-popup-pass {
|
||||
font-size: 11px;
|
||||
color: var(--accent-primary);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 12px;
|
||||
@@ -2437,14 +2448,18 @@ body.first-refresh-blocking .header {
|
||||
}
|
||||
|
||||
#btn-enhance-description {
|
||||
color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Hover: Gold-Flaeche mit weisser Schrift, gleiche Kombination wie
|
||||
.btn-primary. Vorher stand hier die nirgends definierte Variable
|
||||
--accent-primary: der Hintergrund fiel damit auf transparent zurueck,
|
||||
die Schrift blieb weiss, und der Knopf war im Hellmodus unsichtbar. */
|
||||
#btn-enhance-description:hover:not(:disabled) {
|
||||
background: var(--accent-primary);
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.textarea--loading {
|
||||
@@ -2457,7 +2472,7 @@ body.first-refresh-blocking .header {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin-inline 0.8s linear infinite;
|
||||
}
|
||||
@@ -3262,6 +3277,106 @@ body.first-refresh-blocking .header {
|
||||
}
|
||||
|
||||
/* Sidebar Sources Link */
|
||||
/* === Sammelaktionen in der Seitenleiste ============================== */
|
||||
/* Zwei Zustaende. Normal stehen dort nur die beiden Startknoepfe, im
|
||||
Auswahlmodus die Bestaetigungsleiste. Beides sitzt ueber dem Fussbereich
|
||||
mit Quellen und Feedback. */
|
||||
.sidebar-bulk {
|
||||
padding: var(--sp-md) var(--sp-xl) 0;
|
||||
display: flex;
|
||||
gap: var(--sp-md);
|
||||
}
|
||||
|
||||
.sidebar-bulk[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn {
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0 var(--sp-md);
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn.danger:hover:not(:disabled) {
|
||||
background: var(--tint-error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn.ghost {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-bulk-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Bestaetigungsleiste: untereinander, damit die Zaehlung lesbar bleibt */
|
||||
.sidebar-bulk-active {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-md);
|
||||
padding-bottom: var(--sp-md);
|
||||
border-top: 1px solid var(--accent);
|
||||
padding-top: var(--sp-md);
|
||||
}
|
||||
|
||||
.sidebar-bulk-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-bulk-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-bulk-all input {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-bulk-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-md);
|
||||
}
|
||||
|
||||
/* Haken in der Lagenliste, nur im Auswahlmodus sichtbar */
|
||||
.incident-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.incident-check input {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ausgewaehlte Lage: vollstaendig umlaufender Rahmen, kein einseitiger Streifen */
|
||||
.incident-item.picked {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.sidebar-sources-link {
|
||||
padding: var(--sp-lg) var(--sp-xl);
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -6180,3 +6295,22 @@ body.tutorial-active .tutorial-cursor {
|
||||
.pipeline-block.status-active { box-shadow: var(--glow-accent); }
|
||||
.pipeline-stage.is-looping .pipeline-loop { animation: none !important; opacity: 1; }
|
||||
}
|
||||
|
||||
/* Kennzeichen des KI-Wegs (EU-Umbau). Kopfzeile und Seitenleiste. */
|
||||
.incident-ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.incident-ai-badge.ai-eu { background: rgba(16, 185, 129, 0.16); color: #10B981; }
|
||||
.incident-ai-badge.ai-anthropic { background: rgba(148, 163, 184, 0.18); color: var(--text-secondary); }
|
||||
.incident-ai-badge[hidden] { display: none; }
|
||||
|
||||
.sidebar-ai-tag { font-weight: 700; }
|
||||
.sidebar-ai-tag.ai-eu { color: #10B981; }
|
||||
.sidebar-ai-tag.ai-anthropic { color: var(--text-disabled); }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260723a">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260802d">
|
||||
<style>
|
||||
/* Export Modal Radio */
|
||||
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
|
||||
@@ -123,6 +123,46 @@
|
||||
</h2>
|
||||
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
||||
</div>
|
||||
<!-- Sammelaktionen. Der erste Klick schaltet den Auswahlmodus ein,
|
||||
erst dann erscheinen die Haken in der Lagenliste. -->
|
||||
<div class="sidebar-bulk" id="sidebar-bulk-tools">
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn" onclick="App.startBulkMode('archive')" title="Mehrere Lagen archivieren oder wieder aktivieren" data-i18n-attr="title:sidebar.bulk_archive_title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
|
||||
<span data-i18n="action.archive">Archivieren</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn danger" onclick="App.startBulkMode('delete')" title="Mehrere Lagen löschen" data-i18n-attr="title:sidebar.bulk_delete_title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
||||
<span data-i18n="action.delete">Löschen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auswahlmodus. Hier wird die Sammelaktion bestätigt oder verworfen. -->
|
||||
<div class="sidebar-bulk sidebar-bulk-active" id="sidebar-bulk-bar" hidden>
|
||||
<div class="sidebar-bulk-count" id="sidebar-bulk-count" aria-live="polite"></div>
|
||||
<label class="sidebar-bulk-all">
|
||||
<input type="checkbox" id="sidebar-bulk-all" onchange="App.selectAllVisible(this.checked)">
|
||||
<span data-i18n="sidebar.bulk_all">Alle sichtbaren</span>
|
||||
</label>
|
||||
<div class="sidebar-bulk-actions">
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn" id="sidebar-bulk-archive" onclick="App.bulkStatus('archived')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
|
||||
<span>Archivieren</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn" id="sidebar-bulk-activate" onclick="App.bulkStatus('active')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>
|
||||
<span>Aktivieren</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn danger" id="sidebar-bulk-delete" onclick="App.bulkDelete()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
||||
<span>Löschen</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small sidebar-bulk-btn ghost" onclick="App.endBulkMode()" title="Auswahl verwerfen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
<span data-i18n="action.cancel">Abbrechen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-sources-link">
|
||||
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten" data-i18n-attr="title:sidebar.manage_sources_title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>
|
||||
@@ -164,13 +204,14 @@
|
||||
<div class="incident-header-row1">
|
||||
<div class="incident-header-left">
|
||||
<h2 class="incident-header-title" id="incident-title"></h2>
|
||||
<span class="incident-ai-badge" id="incident-ai-badge" hidden></span>
|
||||
</div>
|
||||
<div class="incident-header-actions">
|
||||
<button class="btn btn-primary btn-small" id="refresh-btn" data-i18n="action.refresh">Aktualisieren</button>
|
||||
<button class="btn btn-secondary btn-small" id="edit-incident-btn" data-i18n="action.edit">Bearbeiten</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="App.openExportModal()" data-i18n="action.export">Bericht exportieren</button>
|
||||
<button class="btn btn-secondary btn-small" id="archive-incident-btn" data-i18n="action.archive">Archivieren</button>
|
||||
<button class="btn btn-danger btn-small" id="delete-incident-btn" data-i18n="action.delete">Löschen</button>
|
||||
<button class="btn btn-secondary btn-small" id="archive-incident-btn" title="Diese Lage archivieren"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span data-i18n="action.archive">Archivieren</span></button>
|
||||
<button class="btn btn-danger btn-small" id="delete-incident-btn" title="Diese Lage löschen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span data-i18n="action.delete">Löschen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="incident-header-row2">
|
||||
@@ -411,6 +452,14 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-ai-backend"><span data-i18n="modal.field.ai_backend">KI-Verarbeitung</span> <span class="info-icon tooltip-below" data-tooltip="Steuert, wo die KI-Verarbeitung dieser Lage stattfindet. Standard: es gilt die Vorgabe der Organisation. Anthropic: heutiger Weg mit eingebauter Websuche. EU: Verarbeitung ueber AWS Bedrock in Frankfurt, Recherche ueber den europaeischen Suchindex staan. Prompts und Suchanfragen verlassen den europaeischen Rechtsraum nicht."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
|
||||
<select id="inc-ai-backend">
|
||||
<option value="" data-i18n="modal.option.ai_backend_default">Standard (Vorgabe der Organisation)</option>
|
||||
<option value="cli" data-i18n="modal.option.ai_backend_cli">Anthropic (heutiger Weg)</option>
|
||||
<option value="bedrock" data-i18n="modal.option.ai_backend_eu">EU (Frankfurt, DSGVO-Fassung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-refresh-mode" data-i18n="modal.field.refresh">Aktualisierung</label>
|
||||
<select id="inc-refresh-mode" onchange="toggleRefreshInterval()">
|
||||
@@ -809,11 +858,11 @@
|
||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||
<script src="/static/js/api.js?v=20260725b"></script>
|
||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||
<script src="/static/js/components.js?v=20260723a"></script>
|
||||
<script src="/static/js/components.js?v=20260802d"></script>
|
||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725b"></script>
|
||||
<script src="/static/js/app.js?v=20260802d"></script>
|
||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"sidebar.research": "Recherchen",
|
||||
"sidebar.archive": "Archiv",
|
||||
"sidebar.sources": "Quellen",
|
||||
"sidebar.bulk_all": "Alle sichtbaren",
|
||||
"sidebar.bulk_archive_title": "Mehrere Lagen archivieren oder wieder aktivieren",
|
||||
"sidebar.bulk_delete_title": "Mehrere Lagen löschen",
|
||||
"sidebar.feedback": "Feedback",
|
||||
"sidebar.manage_sources_title": "Quellen verwalten",
|
||||
"sidebar.feedback_title": "Feedback senden",
|
||||
@@ -16,6 +19,7 @@
|
||||
"action.edit": "Bearbeiten",
|
||||
"action.export": "Bericht exportieren",
|
||||
"action.archive": "Archivieren",
|
||||
"action.cancel": "Abbrechen",
|
||||
"action.delete": "Löschen",
|
||||
"action.refreshing": "Läuft...",
|
||||
"action.restore": "Wiederherstellen",
|
||||
@@ -111,7 +115,8 @@
|
||||
"card.sources_overview": "Quellenübersicht",
|
||||
"fc.label.confirmed": "Bestätigt durch mehrere Quellen",
|
||||
"fc.label.unconfirmed": "Nicht unabhängig bestätigt",
|
||||
"fc.label.contradicted": "Widerlegt",
|
||||
"fc.label.contradicted": "Quellen widersprechen sich",
|
||||
"fc.label.false": "Widerlegt",
|
||||
"fc.label.developing": "Faktenlage noch im Fluss",
|
||||
"fc.label.established": "Gesicherter Fakt (3+ Quellen)",
|
||||
"fc.label.disputed": "Umstrittener Sachverhalt",
|
||||
@@ -122,10 +127,12 @@
|
||||
"fc.tooltip.unconfirmed": "Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.",
|
||||
"fc.tooltip.unverified": "Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.",
|
||||
"fc.tooltip.disputed": "Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.",
|
||||
"fc.tooltip.contradicted": "Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.",
|
||||
"fc.tooltip.contradicted": "Widersprüchlich: Die Belege widersprechen einander, keine Seite ist entschieden. Unterschiedliche Zahlen verschiedener Erhebungsstellen gelten nicht als Widerspruch.",
|
||||
"fc.tooltip.false": "Widerlegt: Ein Beleg entkräftet diese Aussage nachweislich.",
|
||||
"fc.chip.confirmed": "Bestätigt",
|
||||
"fc.chip.unconfirmed": "Unbestätigt",
|
||||
"fc.chip.contradicted": "Widerlegt",
|
||||
"fc.chip.contradicted": "Widersprüchlich",
|
||||
"fc.chip.false": "Widerlegt",
|
||||
"fc.chip.developing": "Unklar",
|
||||
"fc.chip.established": "Gesichert",
|
||||
"fc.chip.disputed": "Umstritten",
|
||||
@@ -133,7 +140,7 @@
|
||||
"refresh.no_developments": "Keine neuen Entwicklungen",
|
||||
"refresh.new_articles_suffix": "neue Artikel",
|
||||
"refresh.confirmed_suffix": "Fakten bestätigt",
|
||||
"refresh.contradicted_suffix": "widerlegt",
|
||||
"refresh.contradicted_suffix": "widersprüchlich",
|
||||
"progress.status.queued": "In Warteschlange",
|
||||
"progress.status.researching": "Recherchiert...",
|
||||
"progress.status.deep_researching": "Tiefenrecherche...",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"sidebar.research": "Research",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.sources": "Sources",
|
||||
"sidebar.bulk_all": "All visible",
|
||||
"sidebar.bulk_archive_title": "Archive or reactivate several cases",
|
||||
"sidebar.bulk_delete_title": "Delete several cases",
|
||||
"sidebar.feedback": "Feedback",
|
||||
"sidebar.manage_sources_title": "Manage sources",
|
||||
"sidebar.feedback_title": "Send feedback",
|
||||
@@ -16,6 +19,7 @@
|
||||
"action.edit": "Edit",
|
||||
"action.export": "Export report",
|
||||
"action.archive": "Archive",
|
||||
"action.cancel": "Cancel",
|
||||
"action.delete": "Delete",
|
||||
"action.refreshing": "Running...",
|
||||
"action.restore": "Restore",
|
||||
@@ -111,7 +115,8 @@
|
||||
"card.sources_overview": "Sources overview",
|
||||
"fc.label.confirmed": "Confirmed by multiple sources",
|
||||
"fc.label.unconfirmed": "Not independently confirmed",
|
||||
"fc.label.contradicted": "Contradicted",
|
||||
"fc.label.contradicted": "Sources conflict",
|
||||
"fc.label.false": "Disproved",
|
||||
"fc.label.developing": "Facts still developing",
|
||||
"fc.label.established": "Established fact (3+ sources)",
|
||||
"fc.label.disputed": "Disputed matter",
|
||||
@@ -122,10 +127,12 @@
|
||||
"fc.tooltip.unconfirmed": "Unconfirmed: known from only one source so far. Independent confirmation is pending.",
|
||||
"fc.tooltip.unverified": "Unverified: the claim could not yet be checked against available sources.",
|
||||
"fc.tooltip.disputed": "Disputed: sources disagree. There is both supporting and contradicting evidence.",
|
||||
"fc.tooltip.contradicted": "Contradicted: reliable sources contradict this claim. Likely false.",
|
||||
"fc.tooltip.contradicted": "Conflicting: the evidence contradicts itself and no side is settled. Differing figures from different authorities do not count as a conflict.",
|
||||
"fc.tooltip.false": "Disproved: evidence refutes this claim.",
|
||||
"fc.chip.confirmed": "Confirmed",
|
||||
"fc.chip.unconfirmed": "Unconfirmed",
|
||||
"fc.chip.contradicted": "Contradicted",
|
||||
"fc.chip.contradicted": "Conflicting",
|
||||
"fc.chip.false": "Disproved",
|
||||
"fc.chip.developing": "Developing",
|
||||
"fc.chip.established": "Established",
|
||||
"fc.chip.disputed": "Disputed",
|
||||
@@ -133,7 +140,7 @@
|
||||
"refresh.no_developments": "No new developments",
|
||||
"refresh.new_articles_suffix": "new articles",
|
||||
"refresh.confirmed_suffix": "facts confirmed",
|
||||
"refresh.contradicted_suffix": "contradicted",
|
||||
"refresh.contradicted_suffix": "conflicting",
|
||||
"progress.status.queued": "Queued",
|
||||
"progress.status.researching": "Researching...",
|
||||
"progress.status.deep_researching": "Deep research...",
|
||||
|
||||
@@ -544,6 +544,150 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// === Sammelaktionen in der Seitenleiste ===
|
||||
// Die Haken sind normalerweise weg. Erst ein Klick auf Archivieren oder
|
||||
// Loeschen unten in der Seitenleiste schaltet den Auswahlmodus ein. Das
|
||||
// entspricht dem Verhalten im Studio, damit beide Ansichten sich gleich
|
||||
// bedienen lassen.
|
||||
_bulkMode: null,
|
||||
_bulkSel: new Set(),
|
||||
|
||||
startBulkMode(mode) {
|
||||
this._bulkMode = mode;
|
||||
this._bulkSel.clear();
|
||||
this.renderSidebar();
|
||||
},
|
||||
|
||||
endBulkMode() {
|
||||
this._bulkMode = null;
|
||||
this._bulkSel.clear();
|
||||
const alle = document.getElementById('sidebar-bulk-all');
|
||||
if (alle) alle.checked = false;
|
||||
this.renderSidebar();
|
||||
},
|
||||
|
||||
toggleBulkSel(id, on) {
|
||||
if (on) this._bulkSel.add(id); else this._bulkSel.delete(id);
|
||||
this.renderSidebar();
|
||||
},
|
||||
|
||||
// Sichtbar heisst: was der aktive Filter der Seitenleiste gerade zeigt.
|
||||
_visibleIncidents() {
|
||||
let sichtbar = this.incidents || [];
|
||||
if (this._sidebarFilter === 'mine') {
|
||||
sichtbar = sichtbar.filter(i => i.created_by_username === this._currentUsername);
|
||||
}
|
||||
return sichtbar;
|
||||
},
|
||||
|
||||
selectAllVisible(on) {
|
||||
const sichtbar = this._visibleIncidents();
|
||||
if (on) sichtbar.forEach(i => this._bulkSel.add(i.id));
|
||||
else sichtbar.forEach(i => this._bulkSel.delete(i.id));
|
||||
this.renderSidebar();
|
||||
},
|
||||
|
||||
_renderBulkBar() {
|
||||
const mode = this._bulkMode;
|
||||
const tools = document.getElementById('sidebar-bulk-tools');
|
||||
const bar = document.getElementById('sidebar-bulk-bar');
|
||||
if (tools) tools.hidden = mode !== null;
|
||||
if (bar) bar.hidden = mode === null;
|
||||
if (mode === null) return;
|
||||
|
||||
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
|
||||
const n = gewaehlt.length;
|
||||
const aktive = gewaehlt.filter(i => i.status === 'active').length;
|
||||
const archivierte = n - aktive;
|
||||
|
||||
const zaehler = document.getElementById('sidebar-bulk-count');
|
||||
if (zaehler) {
|
||||
zaehler.textContent = n === 0
|
||||
? (mode === 'delete' ? 'Lagen zum Löschen anhaken' : 'Lagen zum Archivieren anhaken')
|
||||
: (n === 1 ? '1 Lage ausgewählt' : n + ' Lagen ausgewählt');
|
||||
}
|
||||
|
||||
// Nur anbieten, was zum Modus und zur Auswahl passt.
|
||||
const knopfArchiv = document.getElementById('sidebar-bulk-archive');
|
||||
const knopfAktiv = document.getElementById('sidebar-bulk-activate');
|
||||
const knopfLoesch = document.getElementById('sidebar-bulk-delete');
|
||||
if (knopfArchiv) {
|
||||
knopfArchiv.hidden = mode !== 'archive' || aktive === 0;
|
||||
const t = knopfArchiv.querySelector('span');
|
||||
if (t) t.textContent = 'Archivieren (' + aktive + ')';
|
||||
}
|
||||
if (knopfAktiv) {
|
||||
knopfAktiv.hidden = mode !== 'archive' || archivierte === 0;
|
||||
const t = knopfAktiv.querySelector('span');
|
||||
if (t) t.textContent = 'Aktivieren (' + archivierte + ')';
|
||||
}
|
||||
if (knopfLoesch) {
|
||||
knopfLoesch.hidden = mode !== 'delete' || n === 0;
|
||||
const t = knopfLoesch.querySelector('span');
|
||||
if (t) t.textContent = 'Löschen (' + n + ')';
|
||||
}
|
||||
|
||||
const sichtbar = this._visibleIncidents();
|
||||
const alle = document.getElementById('sidebar-bulk-all');
|
||||
if (alle) alle.checked = sichtbar.length > 0 && sichtbar.every(i => this._bulkSel.has(i.id));
|
||||
},
|
||||
|
||||
// Die Sammelaktionen laufen nacheinander ueber die normalen Endpunkte, damit
|
||||
// deren Rechtepruefung greift. Fehlschlaege werden gezaehlt statt still
|
||||
// verschluckt.
|
||||
async _bulkRun(ids, fn, verb) {
|
||||
let ok = 0;
|
||||
const fehlgeschlagen = [];
|
||||
for (const id of ids) {
|
||||
try { await fn(id); ok++; }
|
||||
catch (e) { fehlgeschlagen.push(id); }
|
||||
}
|
||||
this._bulkSel.clear();
|
||||
this._bulkMode = null;
|
||||
await this.loadIncidents();
|
||||
if (fehlgeschlagen.length) {
|
||||
UI.showToast(ok + ' ' + verb + ', ' + fehlgeschlagen.length
|
||||
+ ' fehlgeschlagen (IDs ' + fehlgeschlagen.join(', ') + ')', 'error');
|
||||
} else {
|
||||
UI.showToast(ok + ' ' + (ok === 1 ? 'Lage' : 'Lagen') + ' ' + verb, 'success');
|
||||
}
|
||||
},
|
||||
|
||||
async bulkStatus(status) {
|
||||
const ids = (this.incidents || [])
|
||||
.filter(i => this._bulkSel.has(i.id)
|
||||
&& (status === 'archived' ? i.status === 'active' : i.status !== 'active'))
|
||||
.map(i => i.id);
|
||||
if (!ids.length) return;
|
||||
const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert';
|
||||
await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb);
|
||||
},
|
||||
|
||||
async bulkDelete() {
|
||||
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
|
||||
if (!gewaehlt.length) return;
|
||||
const artikel = gewaehlt.reduce((n, i) => n + (i.article_count || 0), 0);
|
||||
const namen = gewaehlt.slice(0, 8).map(i => '\n\u2022 ' + i.title).join('')
|
||||
+ (gewaehlt.length > 8 ? '\n\u2022 … und ' + (gewaehlt.length - 8) + ' weitere' : '');
|
||||
const frage = gewaehlt.length + ' ' + (gewaehlt.length === 1 ? 'Lage' : 'Lagen')
|
||||
+ ' endgültig löschen?' + namen
|
||||
+ '\n\nDamit gehen ' + artikel + ' Artikel samt Faktenchecks, Lageberichten'
|
||||
+ ' und Karten verloren. Das lässt sich nicht rückgängig machen.'
|
||||
+ '\n\nWenn du sie nur aus der Liste nehmen willst, nutze stattdessen Archivieren.';
|
||||
if (!confirm(frage)) return;
|
||||
|
||||
const ids = gewaehlt.map(i => i.id);
|
||||
const offeneDabei = this.currentIncidentId && ids.includes(this.currentIncidentId);
|
||||
await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht');
|
||||
if (offeneDabei) {
|
||||
this.currentIncidentId = null;
|
||||
const leer = document.getElementById('empty-state');
|
||||
const inhalt = document.getElementById('incident-view');
|
||||
if (leer) leer.style.display = 'flex';
|
||||
if (inhalt) inhalt.style.display = 'none';
|
||||
}
|
||||
},
|
||||
renderSidebar() {
|
||||
const activeContainer = document.getElementById('active-incidents');
|
||||
const researchContainer = document.getElementById('active-research');
|
||||
@@ -597,6 +741,9 @@ const App = {
|
||||
|
||||
// Sidebar-Stats aktualisieren
|
||||
this.updateSidebarStats();
|
||||
|
||||
// Zustand der Sammelaktionen nachziehen
|
||||
this._renderBulkBar();
|
||||
},
|
||||
|
||||
setSidebarFilter(filter) {
|
||||
@@ -926,6 +1073,27 @@ const App = {
|
||||
typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
|
||||
typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
|
||||
|
||||
// KI-Weg-Kennzeichen. Wird nur angezeigt, wenn die Lage ausdruecklich
|
||||
// auf einen Weg gestellt ist. Ohne Angabe gilt die Vorgabe der
|
||||
// Organisation, dann bleibt die Kopfzeile bewusst unbeschriftet.
|
||||
{ const _ai = document.getElementById('incident-ai-badge');
|
||||
if (_ai) {
|
||||
const _b = incident.ai_backend;
|
||||
if (_b === 'bedrock') {
|
||||
_ai.hidden = false;
|
||||
_ai.className = 'incident-ai-badge ai-eu';
|
||||
_ai.textContent = 'EU';
|
||||
_ai.title = 'KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche ueber den europaeischen Suchindex staan';
|
||||
} else if (_b === 'cli') {
|
||||
_ai.hidden = false;
|
||||
_ai.className = 'incident-ai-badge ai-anthropic';
|
||||
_ai.textContent = 'Anthropic';
|
||||
_ai.title = 'KI-Verarbeitung ueber Anthropic mit eingebauter Websuche (heutiger Weg)';
|
||||
} else {
|
||||
_ai.hidden = true;
|
||||
}
|
||||
} }
|
||||
|
||||
// Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
|
||||
const _tI18n = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
|
||||
const _lbLabel = incident.type === 'research'
|
||||
@@ -1726,6 +1894,7 @@ const App = {
|
||||
include_telegram: document.getElementById('inc-telegram').checked,
|
||||
include_x: document.getElementById('inc-x').checked,
|
||||
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
||||
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2163,6 +2332,7 @@ async handleRefresh() {
|
||||
{ const _e = document.getElementById('inc-x'); if (_e) _e.checked = !!incident.include_x; }
|
||||
|
||||
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
|
||||
{ const _e = document.getElementById('inc-ai-backend'); if (_e) _e.value = incident.ai_backend || ''; }
|
||||
updateVisibilityHint();
|
||||
updateSourcesHint();
|
||||
toggleTypeDefaults(true);
|
||||
@@ -2362,7 +2532,8 @@ async handleRefresh() {
|
||||
confirmed: 'Bestätigt',
|
||||
established: 'Gesichert',
|
||||
unconfirmed: 'Unbestätigt',
|
||||
contradicted: 'Widersprochen',
|
||||
contradicted: 'Widersprüchlich',
|
||||
false: 'Widerlegt',
|
||||
disputed: 'Umstritten',
|
||||
developing: 'In Entwicklung',
|
||||
unverified: 'Ungeprüft',
|
||||
|
||||
@@ -59,12 +59,23 @@ const UI = {
|
||||
}
|
||||
}
|
||||
|
||||
// Im Auswahlmodus der Seitenleiste tritt ein Haken an die Stelle des
|
||||
// Statuspunktes und ein Klick waehlt aus, statt die Lage zu oeffnen.
|
||||
const auswahl = App._bulkMode != null;
|
||||
const gewaehlt = auswahl && App._bulkSel.has(incident.id);
|
||||
const klick = auswahl
|
||||
? `App.toggleBulkSel(${incident.id}, !App._bulkSel.has(${incident.id}))`
|
||||
: `App.selectIncident(${incident.id})`;
|
||||
const markierung = auswahl
|
||||
? `<label class="incident-check" title="Auswählen" onclick="event.stopPropagation()"><input type="checkbox" ${gewaehlt ? 'checked' : ''} onchange="App.toggleBulkSel(${incident.id}, this.checked)"></label>`
|
||||
: `<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>`;
|
||||
|
||||
return `
|
||||
<div class="incident-item ${activeClass}${refreshClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
|
||||
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
|
||||
<div class="incident-item ${activeClass}${refreshClass}${gewaehlt ? ' picked' : ''}" data-id="${incident.id}" onclick="${klick}" role="button" tabindex="0">
|
||||
${markierung}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="incident-name">${this.escape(incident.title)}</div>
|
||||
<div class="incident-meta">${incident.article_count} Artikel · ${this.escape(creator)}</div>
|
||||
<div class="incident-meta">${incident.article_count} Artikel · ${this.escape(creator)}${incident.ai_backend === 'bedrock' ? ' · <span class="sidebar-ai-tag ai-eu">EU</span>' : (incident.ai_backend === 'cli' ? ' · <span class="sidebar-ai-tag ai-anthropic">Anthropic</span>' : '')}</div>
|
||||
${refreshStatusHtml}
|
||||
</div>
|
||||
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
|
||||
@@ -82,7 +93,8 @@ const UI = {
|
||||
_fcLabelDefaultsDE: {
|
||||
confirmed: 'Bestätigt durch mehrere Quellen',
|
||||
unconfirmed: 'Nicht unabhängig bestätigt',
|
||||
contradicted: 'Widerlegt',
|
||||
contradicted: 'Quellen widersprechen sich',
|
||||
false: 'Widerlegt',
|
||||
developing: 'Faktenlage noch im Fluss',
|
||||
established: 'Gesicherter Fakt (3+ Quellen)',
|
||||
disputed: 'Umstrittener Sachverhalt',
|
||||
@@ -95,12 +107,14 @@ const UI = {
|
||||
unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
|
||||
unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
|
||||
disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
|
||||
contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.',
|
||||
contradicted: 'Widersprüchlich: Die Belege widersprechen einander, keine Seite ist entschieden. Unterschiedliche Zahlen verschiedener Erhebungsstellen gelten nicht als Widerspruch.',
|
||||
false: 'Widerlegt: Ein Beleg entkräftet diese Aussage nachweislich.',
|
||||
},
|
||||
_fcChipDefaultsDE: {
|
||||
confirmed: 'Bestätigt',
|
||||
unconfirmed: 'Unbestätigt',
|
||||
contradicted: 'Widerlegt',
|
||||
contradicted: 'Widersprüchlich',
|
||||
false: 'Widerlegt',
|
||||
developing: 'Unklar',
|
||||
established: 'Gesichert',
|
||||
disputed: 'Umstritten',
|
||||
@@ -138,7 +152,8 @@ const UI = {
|
||||
factCheckIcons: {
|
||||
confirmed: '✓',
|
||||
unconfirmed: '?',
|
||||
contradicted: '✗',
|
||||
contradicted: '⇄',
|
||||
false: '✗',
|
||||
developing: '↻',
|
||||
established: '✓',
|
||||
disputed: '⚠',
|
||||
@@ -154,7 +169,7 @@ const UI = {
|
||||
factchecks.forEach(fc => {
|
||||
statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
|
||||
});
|
||||
const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
|
||||
const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted', 'false'];
|
||||
const usedStatuses = statusOrder.filter(s => statusCounts[s]);
|
||||
if (usedStatuses.length <= 1) return '';
|
||||
|
||||
@@ -558,7 +573,7 @@ const UI = {
|
||||
const parts = [];
|
||||
if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
|
||||
if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
|
||||
if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
|
||||
if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widersprüchlich');
|
||||
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
|
||||
|
||||
const summaryEl = document.getElementById('progress-complete-summary');
|
||||
|
||||
@@ -170,6 +170,14 @@ const Studio = {
|
||||
const cols = document.getElementById('studio-cols');
|
||||
if (cols) cols.classList.toggle('idle', on);
|
||||
document.getElementById('studio-empty').style.display = on ? '' : 'none';
|
||||
// Ohne Fall wuerde die Lauf-Anzeige sonst mit dem Stand des zuletzt
|
||||
// geoeffneten Falls stehen bleiben. Nur beim Leerschalten neu zeichnen,
|
||||
// beim Oeffnen macht das loadDetail mit den richtigen Daten.
|
||||
if (on) {
|
||||
this.incident = null;
|
||||
this.fresh = null;
|
||||
this._renderLauf();
|
||||
}
|
||||
},
|
||||
|
||||
// === Linke Spalte: Reiter "Fälle" / "Quellen" ===
|
||||
@@ -260,6 +268,7 @@ const Studio = {
|
||||
</label>` : ''}
|
||||
<button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}">
|
||||
<span class="case-title">${UI.escape(i.title)}</span>
|
||||
${Studio._aiTag(i, 'case-ai-tag')}
|
||||
<span class="case-count">${i.article_count}</span>
|
||||
</button>
|
||||
${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr">
|
||||
@@ -326,10 +335,12 @@ const Studio = {
|
||||
const zeigen = !!leer && !this._runningStage && !schonGewaehlt;
|
||||
panel.hidden = !zeigen;
|
||||
|
||||
// "Kompletter Lauf" rechts und die Startfrage sind dieselbe Aktion - nie beide
|
||||
// gleichzeitig zeigen. Wird die Frage nicht (mehr) gestellt, ist der Knopf wieder
|
||||
// da; sonst stünde man nach einem ergebnislosen Lauf ohne Startmoeglichkeit da.
|
||||
const runAll = document.querySelector('.run-all');
|
||||
// Der Stand-Kasten und die Startfrage sind dieselbe Aktion, nie beide
|
||||
// gleichzeitig zeigen. Wird die Frage nicht mehr gestellt, ist der Kasten
|
||||
// wieder da, sonst stuende man nach einem ergebnislosen Lauf ohne
|
||||
// Startmoeglichkeit da. Die Kette darunter bleibt sichtbar, sie erklaert,
|
||||
// was der Startdialog anbietet.
|
||||
const runAll = document.getElementById('lauf-stand');
|
||||
if (runAll) runAll.hidden = zeigen;
|
||||
|
||||
if (!zeigen) return;
|
||||
@@ -341,13 +352,7 @@ const Studio = {
|
||||
if (full) {
|
||||
full.textContent = research
|
||||
? 'Tiefenrecherche in drei Durchläufen (Breite Erfassung, Vertiefung, Konsolidierung), danach Analyse und Faktencheck. Dauert deutlich länger als ein einzelner Lauf.'
|
||||
: 'Sammeln, Analyse, Faktencheck und Karte nacheinander. Am Ende steht ein fertiges Lagebild.';
|
||||
}
|
||||
const auto = document.getElementById('sp-auto-desc');
|
||||
if (auto) {
|
||||
auto.textContent = inc.refresh_mode === 'auto'
|
||||
? 'Ist für diesen Fall bereits eingeschaltet - der erste Lauf startet von selbst innerhalb einer Minute.'
|
||||
: `Der Fall läuft ab jetzt selbstständig (alle ${inc.refresh_interval || 15} Minuten). Der erste Lauf beginnt sofort.`;
|
||||
: 'Artikel holen, Lagebild schreiben, Fakten prüfen und Orte erkennen, nacheinander. Am Ende steht ein fertiges Lagebild.';
|
||||
}
|
||||
this._checkSearchStatus();
|
||||
},
|
||||
@@ -377,7 +382,7 @@ const Studio = {
|
||||
// kommt zurück (beide sind dieselbe Aktion).
|
||||
localStorage.setItem('studio_start_' + inc.id, kind);
|
||||
panel.hidden = true;
|
||||
const runAll = document.querySelector('.run-all');
|
||||
const runAll = document.getElementById('lauf-stand');
|
||||
if (runAll) runAll.hidden = false;
|
||||
|
||||
if (kind === 'ingest') {
|
||||
@@ -387,21 +392,10 @@ const Studio = {
|
||||
document.getElementById('ingest-add-btn').focus();
|
||||
return;
|
||||
}
|
||||
if (kind === 'auto') {
|
||||
try {
|
||||
await API.updateIncident(inc.id, { refresh_mode: 'auto' });
|
||||
inc.refresh_mode = 'auto';
|
||||
await this.loadIncidents();
|
||||
UI.showToast('Automatische Aktualisierung eingeschaltet - der erste Lauf startet gleich.', 'success');
|
||||
} catch (e) {
|
||||
UI.showToast((e && e.message) || 'Umstellen fehlgeschlagen', 'error');
|
||||
localStorage.removeItem('studio_start_' + inc.id);
|
||||
panel.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 'full' (kompletter Lauf) oder 'collect' (nur sammeln)
|
||||
this.runStage(kind);
|
||||
// Bleibt 'full'. Einzelne Schritte lassen sich nicht mehr starten, und
|
||||
// die automatische Aktualisierung ist eine Einstellung, keine Startaktion.
|
||||
// Sie steht im Reiter Einstellungen des Falls.
|
||||
this.runStage('full');
|
||||
},
|
||||
|
||||
// === Auswahlmodus + Massenaktionen ===
|
||||
@@ -459,9 +453,15 @@ const Studio = {
|
||||
const ba = document.getElementById('bulk-archive');
|
||||
const bv = document.getElementById('bulk-activate');
|
||||
const bd = document.getElementById('bulk-delete');
|
||||
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; ba.textContent = `Archivieren (${sel.filter(i => i.status === 'active').length})`; }
|
||||
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; bv.textContent = `Aktivieren (${sel.filter(i => i.status !== 'active').length})`; }
|
||||
if (bd) { bd.hidden = mode !== 'delete' || n === 0; bd.textContent = `Löschen (${n})`; }
|
||||
// Beschriftung in die span, sonst wuerde textContent das Icon entfernen.
|
||||
const setzeText = (el, text) => {
|
||||
if (!el) return;
|
||||
const ziel = el.querySelector('span');
|
||||
if (ziel) ziel.textContent = text; else el.textContent = text;
|
||||
};
|
||||
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; setzeText(ba, `Archivieren (${sel.filter(i => i.status === 'active').length})`); }
|
||||
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; setzeText(bv, `Aktivieren (${sel.filter(i => i.status !== 'active').length})`); }
|
||||
if (bd) { bd.hidden = mode !== 'delete' || n === 0; setzeText(bd, `Löschen (${n})`); }
|
||||
|
||||
// "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist
|
||||
const vis = this._visibleCases();
|
||||
@@ -685,6 +685,10 @@ const Studio = {
|
||||
badge.textContent = research ? 'Analyse' : 'Live';
|
||||
badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
|
||||
badge.style.display = '';
|
||||
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
|
||||
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
|
||||
const aiBadge = document.getElementById('studio-ai-badge');
|
||||
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
|
||||
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
|
||||
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
|
||||
},
|
||||
@@ -1327,6 +1331,233 @@ const Studio = {
|
||||
},
|
||||
|
||||
// === Studio-Tabs in der Mitte ===
|
||||
// === Einstellungen des Falls =========================================
|
||||
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
|
||||
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
|
||||
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
|
||||
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
|
||||
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
|
||||
// verstellen.
|
||||
|
||||
_setEl(id) { return document.getElementById(id); },
|
||||
|
||||
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
|
||||
_settingsWerte() {
|
||||
const g = (id) => this._setEl(id);
|
||||
const einheit = parseInt(g('set-refresh-unit').value) || 1;
|
||||
const zahl = parseInt(g('set-refresh-value').value) || 15;
|
||||
const auto = g('set-refresh-mode').value === 'auto';
|
||||
const werte = {
|
||||
title: g('set-title').value.trim(),
|
||||
description: g('set-description').value.trim(),
|
||||
international_sources: g('set-international').checked,
|
||||
include_telegram: g('set-telegram').checked,
|
||||
include_x: g('set-x').checked,
|
||||
visibility: g('set-visibility').checked ? 'public' : 'private',
|
||||
ai_backend: g('set-ai-backend').value || '',
|
||||
refresh_mode: g('set-refresh-mode').value,
|
||||
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
|
||||
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
|
||||
};
|
||||
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
|
||||
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
|
||||
// ausgewertet wird er dort ohnehin nicht.
|
||||
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
|
||||
return werte;
|
||||
},
|
||||
|
||||
_settingsAbo() {
|
||||
return {
|
||||
notify_email_summary: this._setEl('set-notify-summary').checked,
|
||||
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
|
||||
notify_email_status_change: this._setEl('set-notify-status-change').checked,
|
||||
};
|
||||
},
|
||||
|
||||
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
|
||||
async loadSettings() {
|
||||
const inc = this.incident;
|
||||
if (!inc || !this._setEl('set-title')) return;
|
||||
const g = (id) => this._setEl(id);
|
||||
|
||||
g('set-title').value = inc.title || '';
|
||||
g('set-description').value = inc.description || '';
|
||||
g('set-international').checked = !!inc.international_sources;
|
||||
g('set-telegram').checked = !!inc.include_telegram;
|
||||
g('set-x').checked = !!inc.include_x;
|
||||
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
|
||||
g('set-ai-backend').value = inc.ai_backend || '';
|
||||
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
|
||||
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
|
||||
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
|
||||
? 0 : inc.retention_days;
|
||||
|
||||
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
|
||||
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
|
||||
const iv = parseInt(inc.refresh_interval) || 15;
|
||||
let einheit = 1;
|
||||
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
|
||||
g('set-refresh-unit').value = String(einheit);
|
||||
g('set-refresh-value').value = String(Math.round(iv / einheit));
|
||||
|
||||
this.settingsRefreshToggle();
|
||||
this.settingsIntervalMin();
|
||||
this.settingsVisibilityHint();
|
||||
this._settingsRunningNote();
|
||||
|
||||
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
|
||||
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
|
||||
// Formular nicht in einem halben Zustand zuruecklaesst.
|
||||
this._settingsAlt = this._settingsWerte();
|
||||
this.settingsAiChanged();
|
||||
this._settingsState('');
|
||||
|
||||
try {
|
||||
const abo = await API.getSubscription(inc.id);
|
||||
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
|
||||
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
|
||||
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
|
||||
} catch (e) {
|
||||
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
|
||||
// fuer einen Fall ohne Abo.
|
||||
g('set-notify-summary').checked = false;
|
||||
g('set-notify-new-articles').checked = false;
|
||||
g('set-notify-status-change').checked = false;
|
||||
}
|
||||
this._settingsAboAlt = this._settingsAbo();
|
||||
|
||||
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
|
||||
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
|
||||
await this._settingsXHint();
|
||||
},
|
||||
|
||||
settingsRefreshToggle() {
|
||||
const auto = this._setEl('set-refresh-mode').value === 'auto';
|
||||
this._setEl('set-interval-field').classList.toggle('visible', auto);
|
||||
this._setEl('set-starttime-field').classList.toggle('visible', auto);
|
||||
},
|
||||
|
||||
settingsIntervalMin() {
|
||||
const einheit = parseInt(this._setEl('set-refresh-unit').value);
|
||||
const feld = this._setEl('set-refresh-value');
|
||||
const min = einheit === 1 ? 10 : 1;
|
||||
feld.min = min;
|
||||
if (parseInt(feld.value) < min) feld.value = min;
|
||||
},
|
||||
|
||||
settingsVisibilityHint() {
|
||||
const oeffentlich = this._setEl('set-visibility').checked;
|
||||
this._setEl('set-visibility-text').textContent = oeffentlich
|
||||
? 'Öffentlich, für alle Nutzer sichtbar'
|
||||
: 'Privat, nur für dich sichtbar';
|
||||
},
|
||||
|
||||
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
|
||||
settingsAiChanged() {
|
||||
const w = this._setEl('set-ai-warn');
|
||||
if (!w) return;
|
||||
const jetzt = this._setEl('set-ai-backend').value || '';
|
||||
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
|
||||
w.hidden = jetzt === alt;
|
||||
},
|
||||
|
||||
_settingsRunningNote() {
|
||||
const n = this._setEl('set-running-note');
|
||||
if (n) n.hidden = !this._runningStage;
|
||||
},
|
||||
|
||||
_settingsState(text, art) {
|
||||
const s = this._setEl('set-state');
|
||||
if (!s) return;
|
||||
s.textContent = text || '';
|
||||
s.className = 'settings-state' + (art ? ' ' + art : '');
|
||||
},
|
||||
|
||||
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
|
||||
async _settingsXHint() {
|
||||
const cb = this._setEl('set-x');
|
||||
const hint = this._setEl('set-x-hint');
|
||||
if (!cb) return;
|
||||
let aktiv = 0;
|
||||
try {
|
||||
const konten = await API.listXAccounts();
|
||||
aktiv = (konten || []).filter(a => a.active).length;
|
||||
} catch (e) {
|
||||
aktiv = 0; // im Zweifel sperren
|
||||
}
|
||||
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
|
||||
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
|
||||
cb.disabled = aktiv === 0 && !cb.checked;
|
||||
if (hint) hint.hidden = aktiv !== 0;
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
const inc = this.incident;
|
||||
if (!inc || !this._setEl('set-title')) return;
|
||||
const knopf = this._setEl('set-save');
|
||||
|
||||
const jetzt = this._settingsWerte();
|
||||
if (!jetzt.title) {
|
||||
UI.showToast('Ohne Titel geht es nicht', 'error');
|
||||
this._setEl('set-title').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const alt = this._settingsAlt || {};
|
||||
const patch = {};
|
||||
Object.keys(jetzt).forEach(k => {
|
||||
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
|
||||
});
|
||||
|
||||
const abo = this._settingsAbo();
|
||||
const aboAlt = this._settingsAboAlt || {};
|
||||
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
|
||||
|
||||
if (!Object.keys(patch).length && !aboGeaendert) {
|
||||
this._settingsState('Nichts geändert');
|
||||
return;
|
||||
}
|
||||
|
||||
knopf.disabled = true;
|
||||
this._settingsState('Wird gespeichert …');
|
||||
try {
|
||||
if (Object.keys(patch).length) {
|
||||
const neu = await API.updateIncident(inc.id, patch);
|
||||
// Der Server antwortet mit der angereicherten Lage. Sie ist die
|
||||
// verlaessliche Quelle, das Formular koennte abweichen.
|
||||
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
|
||||
const i = this.incidents.findIndex(x => x.id === inc.id);
|
||||
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
|
||||
}
|
||||
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
|
||||
|
||||
this._settingsAlt = this._settingsWerte();
|
||||
this._settingsAboAlt = abo;
|
||||
this.settingsAiChanged();
|
||||
this.renderHeader(this.incident);
|
||||
this.renderCases();
|
||||
this._settingsState('Gespeichert', 'ok');
|
||||
UI.showToast('Einstellungen gespeichert', 'success');
|
||||
} catch (e) {
|
||||
this._settingsState('Nicht gespeichert', 'fehler');
|
||||
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
|
||||
_aiTag(inc, klasse) {
|
||||
const b = inc && inc.ai_backend;
|
||||
if (b === 'bedrock') {
|
||||
return `<span class="${klasse} ai-eu" title="KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche über den europäischen Suchindex staan">EU</span>`;
|
||||
}
|
||||
if (b === 'cli') {
|
||||
return `<span class="${klasse} ai-anthropic" title="KI-Verarbeitung über Anthropic mit eingebauter Websuche (heutiger Weg)">Anthropic</span>`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
_tabTitle(key) {
|
||||
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
|
||||
return el ? el.textContent.trim() : key;
|
||||
@@ -1389,6 +1620,7 @@ const Studio = {
|
||||
}
|
||||
if (key === 'snapshots') this.loadSnapshots();
|
||||
if (key === 'timeline') this.loadTimeline();
|
||||
if (key === 'settings') this.loadSettings();
|
||||
},
|
||||
|
||||
closeTab(key, ev) {
|
||||
@@ -1573,7 +1805,7 @@ const Studio = {
|
||||
card.innerHTML = `<div class="chat-offer-title">Gezielte Recherche abgeschlossen.</div>
|
||||
<div class="chat-offer-text">${n} neue Meldung${n === 1 ? '' : 'en'} zur Frage erfasst. Starte Analyse oder Faktencheck neu und stelle die Frage erneut.</div>`;
|
||||
this._tlLoaded = false;
|
||||
try { this.fresh = await API.getFreshness(incId); this._renderFreshness(); } catch (e) {}
|
||||
try { this.fresh = await API.getFreshness(incId); this._renderLauf(); } catch (e) {}
|
||||
} else if (st && st.status === 'error') {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
|
||||
if (textEl) textEl.textContent = 'Die Recherche ist fehlgeschlagen. Bitte erneut versuchen.';
|
||||
@@ -1584,65 +1816,91 @@ const Studio = {
|
||||
setTimeout(tick, 3000);
|
||||
},
|
||||
|
||||
// === Bausteine (Kacheln in Spalte 3) ===
|
||||
// Ein Klick startet den Baustein sofort. Läuft er, zeigt die Kachel Spinner +
|
||||
// Fortschritt; bei den Orchestrator-Bausteinen (Sammeln, Kompletter Lauf) bricht
|
||||
// ein zweiter Klick ab. Analyse/Faktencheck/Geoparsing haben backendseitig keinen
|
||||
// Abbruch - ihre Kachel ist während des Laufs gesperrt statt einen toten Knopf zu zeigen.
|
||||
// === Der Lauf (Spalte 3) =============================================
|
||||
// Es gibt genau einen Startpunkt. Die vier Schritte darunter sind Anzeige,
|
||||
// sie starten nichts. Aufklappen erklaert nur, was ein Schritt tut.
|
||||
//
|
||||
// Der Server kann einzelne Bausteine weiterhin starten (POST .../stage).
|
||||
// Die Oberflaeche nutzt das nicht mehr, zeigt einen so gestarteten Lauf
|
||||
// aber korrekt an, denn er kann aus der klassischen Ansicht kommen.
|
||||
_CANCELLABLE: ['collect', 'full'],
|
||||
// 'network' hat keine Karte mehr, das Label bleibt trotzdem stehen. Kaeme
|
||||
// der Baustein im Server, meldete ein laufender Schritt sonst nur seinen
|
||||
// technischen Schluessel.
|
||||
_STAGE_LABELS: {
|
||||
collect: 'Sammeln', analyze: 'Analyse', factcheck: 'Faktencheck',
|
||||
geoparse: 'Geoparsing', network: 'Netzwerkanalyse', full: 'Kompletter Lauf',
|
||||
collect: 'Artikel holen', analyze: 'Lagebild schreiben',
|
||||
factcheck: 'Fakten prüfen', geoparse: 'Orte erkennen',
|
||||
network: 'Netzwerkanalyse', full: 'Kompletter Lauf',
|
||||
},
|
||||
|
||||
// "Aktualisieren" von außen (Tastatur/WS-Resync) = kompletter Lauf
|
||||
_SCHRITT_ICONS: {
|
||||
collect: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
analyze: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
|
||||
factcheck: '<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/>',
|
||||
geoparse: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
|
||||
},
|
||||
|
||||
_SCHRITT_WAS: {
|
||||
collect: 'Durchsucht die ausgewählten Quellen nach neuen Meldungen. Ohne diesen Schritt haben die folgenden nichts, womit sie arbeiten können.',
|
||||
analyze: 'Wertet alle Artikel aus und schreibt daraus den zusammenhängenden Bericht. Der bisherige Bericht bleibt im Verlauf erhalten.',
|
||||
factcheck: 'Zieht die Tatsachenbehauptungen aus dem Bericht und sucht nach Belegen und Widersprüchen. Der bisherige Stand wird als Lauf archiviert.',
|
||||
geoparse: 'Findet Ortsangaben in den Artikeln und setzt sie auf die Karte. Läuft nur über Artikel, die noch nicht geprüft wurden.',
|
||||
},
|
||||
|
||||
_schrittOffen: null,
|
||||
|
||||
/** Von aussen (Tastatur, WebSocket-Resync) heisst Aktualisieren kompletter Lauf. */
|
||||
async refresh() { return this.runStage('full'); },
|
||||
|
||||
// Alle Start-Elemente tragen data-stage: die ↻-Knoepfe in den Karten, "Kompletter
|
||||
// Lauf" und der Sammeln-Knopf in der Quellenspalte.
|
||||
/**
|
||||
* Der eine Startknopf. Laeuft gerade ein abbrechbarer Schritt, bricht er ab,
|
||||
* sonst startet er den kompletten Lauf.
|
||||
*
|
||||
* Wichtig ist der laufende Schritt, nicht 'full'. Ein Lauf kann aus der
|
||||
* klassischen Ansicht als 'collect' gestartet worden sein. Ein Abbruch auf
|
||||
* 'full' wuerde ihn dann nicht treffen, obwohl der Knopf Abbrechen anzeigt.
|
||||
*/
|
||||
async laufKnopf() {
|
||||
const run = this._runningStage;
|
||||
if (run && this._CANCELLABLE.includes(run)) return this.runStage(run);
|
||||
if (run) {
|
||||
UI.showToast('Dieser Schritt lässt sich nicht abbrechen.', 'info');
|
||||
return;
|
||||
}
|
||||
return this.runStage('full');
|
||||
},
|
||||
|
||||
_stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); },
|
||||
_stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); },
|
||||
|
||||
_stagesIdle() {
|
||||
this._runningStage = null;
|
||||
// Ohne Artikel können Analyse, Faktencheck und Geoparsing nicht laufen
|
||||
// (das Backend wirft "Keine Artikel vorhanden") - erst gar nicht anbieten.
|
||||
const ohneArtikel = !(this.articles || []).length;
|
||||
const brauchtArtikel = ['analyze', 'factcheck', 'geoparse'];
|
||||
this._stageEls().forEach(b => {
|
||||
b.classList.remove('running');
|
||||
const s = b.dataset.stage;
|
||||
// Netzwerkanalyse bleibt gesperrt (Baustein folgt im Backend)
|
||||
b.disabled = (s === 'network') || !this.incident
|
||||
|| (ohneArtikel && brauchtArtikel.includes(s));
|
||||
b.disabled = !this.incident;
|
||||
});
|
||||
document.querySelectorAll('.art-card').forEach(c => c.classList.remove('busy'));
|
||||
const ra = document.querySelector('.run-all .ra-state');
|
||||
if (ra) ra.textContent = '';
|
||||
this._renderFreshness();
|
||||
this._renderLauf();
|
||||
},
|
||||
|
||||
_stageRunning(stage, text) {
|
||||
if (!stage) return;
|
||||
this._runningStage = stage;
|
||||
// Sobald etwas läuft, ist die Startfrage beantwortet - auch wenn der Lauf
|
||||
// über den Sammeln-Knopf links statt über das Panel ausgelöst wurde.
|
||||
// Sobald etwas läuft, ist die Startfrage beantwortet.
|
||||
const panel = document.getElementById('start-panel');
|
||||
if (panel) panel.hidden = true;
|
||||
if (this.incident) localStorage.setItem('studio_start_' + this.incident.id, 'run');
|
||||
const cancellable = this._CANCELLABLE.includes(stage);
|
||||
this._stageEls().forEach(b => {
|
||||
const isThis = b.dataset.stage === stage;
|
||||
b.classList.toggle('running', isThis);
|
||||
// Die laufende Aktion bleibt nur klickbar, wenn sie abbrechbar ist
|
||||
b.disabled = isThis ? !cancellable : true;
|
||||
b.classList.add('running');
|
||||
// Der Startknopf bleibt nur klickbar, wenn sich der Lauf abbrechen lässt
|
||||
b.disabled = !cancellable;
|
||||
});
|
||||
document.querySelectorAll('.art-card').forEach(c => {
|
||||
c.classList.toggle('busy', c.dataset.card === stage);
|
||||
});
|
||||
const ra = document.querySelector('.run-all .ra-state');
|
||||
if (ra) ra.textContent = stage === 'full' ? (text || 'läuft …') + ' · Abbrechen' : '';
|
||||
this._renderFreshness(text);
|
||||
this._renderLauf(text);
|
||||
},
|
||||
|
||||
klappSchritt(i) {
|
||||
this._schrittOffen = (this._schrittOffen === i) ? null : i;
|
||||
this._renderLauf();
|
||||
},
|
||||
|
||||
// App-Zeitstempel stehen bereits in lokaler Zeit -> nur umformatieren, NICHT als UTC lesen
|
||||
@@ -1651,102 +1909,218 @@ const Studio = {
|
||||
return m ? `${m[3]}.${m[2]}. ${m[4]}:${m[5]}` : '';
|
||||
},
|
||||
|
||||
_setNote(card, cls, html) {
|
||||
const el = document.querySelector(`.art-note[data-note="${card}"]`);
|
||||
if (!el) return;
|
||||
el.className = 'art-note show ' + cls;
|
||||
el.innerHTML = html;
|
||||
_svg(pfad, groesse) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${groesse}" height="${groesse}" `
|
||||
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
|
||||
+ `stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${pfad}</svg>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Datenstand je Karte anzeigen. Die Karte meldet selbst, ob ihr Ergebnis noch
|
||||
* zur Datenlage passt - so muss niemand die Pipeline im Kopf haben.
|
||||
* Zustand der vier Schritte aus /freshness ableiten.
|
||||
* 'fertig' | 'veraltet' | 'offen' | 'gesperrt' | 'laeuft' | 'wartet'
|
||||
*/
|
||||
_renderFreshness(runText) {
|
||||
const f = this.fresh;
|
||||
_schrittZustaende() {
|
||||
const f = this.fresh || {};
|
||||
const run = this._runningStage;
|
||||
const spin = '<span class="an-spin"></span>';
|
||||
const btn = (stage, label) =>
|
||||
`<button type="button" onclick="Studio.runStage('${stage}')">${label}</button>`;
|
||||
const arts = f.articles || 0;
|
||||
const s = f.summary || {};
|
||||
const fc = f.factcheck || {};
|
||||
const geo = f.geoparse || {};
|
||||
|
||||
// Sammeln-Knopf spiegelt den Lauf mit
|
||||
const cb = document.getElementById('collect-btn');
|
||||
if (cb) cb.classList.toggle('running', run === 'collect');
|
||||
const zust = (key, ohneLauf) => {
|
||||
if (run === key) return 'laeuft';
|
||||
// Der komplette Lauf und das Sammeln arbeiten alle Schritte ab
|
||||
if (run === 'full' || run === 'collect') return 'wartet';
|
||||
if (run) return 'wartet';
|
||||
return ohneLauf();
|
||||
};
|
||||
|
||||
const running = (card, label) =>
|
||||
this._setNote(card, 'running', `${spin} ${label} läuft …`);
|
||||
const waiting = (card) =>
|
||||
this._setNote(card, 'blocked',
|
||||
`wartet, bis ${this._STAGE_LABELS[run] || 'der Baustein'} fertig ist`);
|
||||
return [
|
||||
{
|
||||
key: 'collect',
|
||||
zustand: zust('collect', () => (arts ? 'fertig' : 'offen')),
|
||||
stand: run === 'collect' ? 'läuft gerade'
|
||||
: arts ? `${arts} Artikel${f.last_collect ? ', ' + this._fmtStamp(f.last_collect) : ''}`
|
||||
: 'noch nicht gelaufen',
|
||||
},
|
||||
{
|
||||
key: 'analyze',
|
||||
zustand: zust('analyze', () => !arts ? 'gesperrt'
|
||||
: !s.exists ? 'offen'
|
||||
: s.pending > 0 ? 'veraltet' : 'fertig'),
|
||||
stand: run === 'analyze' ? 'läuft gerade'
|
||||
: !arts ? 'wartet auf Artikel'
|
||||
: !s.exists ? 'noch nicht erzeugt'
|
||||
: s.pending > 0 ? `${s.pending} neue Artikel seit dem Bericht`
|
||||
: `aktuell, ${this._fmtStamp(s.last)}`,
|
||||
},
|
||||
{
|
||||
key: 'factcheck',
|
||||
zustand: zust('factcheck', () => !arts ? 'gesperrt'
|
||||
: !fc.exists ? 'offen'
|
||||
: fc.pending > 0 ? 'veraltet' : 'fertig'),
|
||||
stand: run === 'factcheck' ? 'läuft gerade'
|
||||
: !arts ? 'wartet auf Artikel'
|
||||
: !fc.exists ? 'noch nicht erzeugt'
|
||||
: fc.pending > 0 ? `${fc.pending} neue Artikel seit der Prüfung`
|
||||
: `${fc.facts} Fakten geprüft`,
|
||||
},
|
||||
{
|
||||
key: 'geoparse',
|
||||
zustand: zust('geoparse', () => !arts ? 'gesperrt'
|
||||
: geo.pending > 0 ? 'veraltet' : 'fertig'),
|
||||
stand: run === 'geoparse' ? 'läuft gerade'
|
||||
: !arts ? 'wartet auf Artikel'
|
||||
: geo.pending > 0 ? `${geo.pending} Artikel noch nicht verortet`
|
||||
: 'alle Artikel verortet',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Ohne Freshness-Daten (alte Lage frisch geladen / Endpunkt weg): Karten neutral lassen
|
||||
const cards = ['analyze', 'factcheck', 'geoparse'];
|
||||
if (run) {
|
||||
cards.forEach(c => (run === c ? running(c, this._STAGE_LABELS[c]) : waiting(c)));
|
||||
if (run === 'collect' || run === 'full') {
|
||||
cards.forEach(c => waiting(c));
|
||||
/** Stand-Kasten, Kette und Schrittliste zeichnen. */
|
||||
_renderLauf(runText) {
|
||||
const kasten = document.getElementById('lauf-stand');
|
||||
if (!kasten) return;
|
||||
const inc = this.incident;
|
||||
const f = this.fresh || {};
|
||||
const run = this._runningStage;
|
||||
const arts = f.articles || 0;
|
||||
const research = inc && inc.type === 'research';
|
||||
|
||||
const haupt = document.getElementById('ls-haupt');
|
||||
const neben = document.getElementById('ls-neben');
|
||||
const knopf = document.getElementById('ls-btn');
|
||||
const text = knopf && knopf.querySelector('.ls-text');
|
||||
const iconEl = knopf && knopf.querySelector('.ls-icon');
|
||||
const hinweis = document.getElementById('ls-hinweis');
|
||||
|
||||
const ICON_LAUF = '<path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/>';
|
||||
const ICON_STOPP = '<rect width="12" height="12" x="6" y="6" rx="1"/>';
|
||||
|
||||
// --- Stand-Kasten ---
|
||||
let ueberschrift = '';
|
||||
let knopfText = 'Aktualisieren';
|
||||
let abbruch = false;
|
||||
let betont = false;
|
||||
|
||||
if (!inc) {
|
||||
ueberschrift = 'Kein Fall geöffnet';
|
||||
} else if (run) {
|
||||
ueberschrift = (this._STAGE_LABELS[run] || 'Ein Schritt') + ' läuft gerade';
|
||||
abbruch = this._CANCELLABLE.includes(run);
|
||||
knopfText = abbruch ? 'Abbrechen' : (runText || 'läuft …');
|
||||
} else if (!arts) {
|
||||
ueberschrift = 'Noch keine Artikel gesammelt';
|
||||
knopfText = research ? 'Recherche starten' : 'Lauf starten';
|
||||
betont = true;
|
||||
} else {
|
||||
const offen = this._schrittZustaende()
|
||||
.filter(s => s.zustand === 'veraltet' || s.zustand === 'offen');
|
||||
if (offen.length) {
|
||||
const s = f.summary || {};
|
||||
ueberschrift = s.pending > 0
|
||||
? `${s.pending} neue Artikel seit dem letzten Bericht`
|
||||
: 'Ergebnisse sind nicht auf dem neuesten Stand';
|
||||
knopfText = 'Jetzt aktualisieren';
|
||||
betont = true;
|
||||
} else {
|
||||
ueberschrift = 'Alle Ergebnisse auf dem neuesten Stand';
|
||||
knopfText = 'Erneut durchlaufen';
|
||||
}
|
||||
}
|
||||
this._setNote('network', 'blocked', 'Baustein folgt');
|
||||
this._setNote('timeline', 'fresh', 'entsteht automatisch aus den Artikeln');
|
||||
if (run || !f) return;
|
||||
|
||||
kasten.classList.toggle('betont', betont);
|
||||
if (haupt) {
|
||||
haupt.textContent = ueberschrift;
|
||||
haupt.classList.toggle('warn', betont && !!arts);
|
||||
}
|
||||
if (neben) {
|
||||
neben.textContent = !inc ? ''
|
||||
: arts ? `${arts} Artikel im Bestand`
|
||||
: 'Noch kein Bestand';
|
||||
}
|
||||
if (text) text.textContent = knopfText;
|
||||
if (iconEl) iconEl.innerHTML = this._svg(abbruch ? ICON_STOPP : ICON_LAUF, 16);
|
||||
if (knopf) {
|
||||
knopf.classList.toggle('abbrechen', abbruch);
|
||||
knopf.disabled = !inc || (!!run && !abbruch);
|
||||
knopf.title = abbruch ? 'Laufenden Schritt abbrechen'
|
||||
: research ? 'Alle Schritte nacheinander, am Ende steht ein fertiger Recherchebericht'
|
||||
: 'Alle Schritte nacheinander, am Ende steht ein fertiges Lagebild';
|
||||
}
|
||||
if (hinweis) {
|
||||
hinweis.textContent = (!inc || run || arts) ? ''
|
||||
: 'Danach entstehen Bericht, Faktencheck und Karte von selbst.';
|
||||
hinweis.hidden = !hinweis.textContent;
|
||||
}
|
||||
|
||||
// --- Kette und Schritte ---
|
||||
const schritte = inc ? this._schrittZustaende() : [];
|
||||
const kette = document.getElementById('lauf-kette');
|
||||
const fortschritt = document.getElementById('lauf-fortschritt');
|
||||
const liste = document.getElementById('lauf-schritte');
|
||||
|
||||
if (kette) {
|
||||
kette.innerHTML = schritte.map((s, i) => {
|
||||
const kn = `<span class="kn kn-${s.zustand}"></span>`;
|
||||
if (i === schritte.length - 1) return kn;
|
||||
const voll = s.zustand === 'fertig' && schritte[i + 1].zustand === 'fertig';
|
||||
return kn + `<span class="kn-linie${voll ? ' aktiv' : ''}"></span>`;
|
||||
}).join('');
|
||||
}
|
||||
if (fortschritt) {
|
||||
const fertig = schritte.filter(s => s.zustand === 'fertig').length;
|
||||
fortschritt.textContent = inc
|
||||
? `${fertig} von ${schritte.length} Schritten auf dem neuesten Stand` : '';
|
||||
}
|
||||
|
||||
if (liste) {
|
||||
liste.innerHTML = schritte.map((s, i) => {
|
||||
const auf = this._schrittOffen === i;
|
||||
const name = (s.key === 'analyze' && research)
|
||||
? 'Recherchebericht schreiben' : this._STAGE_LABELS[s.key];
|
||||
const marke = s.zustand === 'fertig'
|
||||
? this._svg('<path d="M20 6 9 17l-5-5"/>', 12) : String(i + 1);
|
||||
return `<div class="schritt schritt-${s.zustand}${auf ? ' offen' : ''}">
|
||||
<button class="schritt-kopf" type="button" onclick="Studio.klappSchritt(${i})"
|
||||
aria-expanded="${auf}">
|
||||
<span class="schritt-nr">${marke}</span>
|
||||
<span class="schritt-txt">
|
||||
<span class="schritt-name">${UI.escape(name)}</span>
|
||||
<span class="schritt-stand">${UI.escape(s.stand)}</span>
|
||||
</span>
|
||||
<span class="schritt-pfeil">${this._svg('<polyline points="6 9 12 15 18 9"/>', 13)}</span>
|
||||
</button>
|
||||
<div class="schritt-mehr">${UI.escape(this._SCHRITT_WAS[s.key] || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// --- Nebenangaben an den Ansichten ---
|
||||
this._renderMeta();
|
||||
},
|
||||
|
||||
/** Zusatzangaben an den Ansichtsknoepfen, aus /freshness. */
|
||||
_renderMeta() {
|
||||
const f = this.fresh;
|
||||
if (!f) return;
|
||||
const arts = f.articles || 0;
|
||||
|
||||
// --- Lagebild (Analyse) ---
|
||||
const s = f.summary || {};
|
||||
const sumMeta = document.getElementById('art-summary-meta');
|
||||
if (sumMeta) {
|
||||
sumMeta.textContent = s.exists
|
||||
? `${arts} Artikel ausgewertet · ${this._fmtStamp(s.last)}`
|
||||
: 'noch nicht erzeugt';
|
||||
}
|
||||
if (!arts) {
|
||||
this._setNote('analyze', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
|
||||
} else if (!s.exists) {
|
||||
this._setNote('analyze', 'blocked', 'noch nicht erzeugt' + btn('analyze', 'Erzeugen'));
|
||||
} else if (s.pending > 0) {
|
||||
this._setNote('analyze', 'stale',
|
||||
`${s.pending} neue Artikel seit dem letzten Bericht` + btn('analyze', 'Aktualisieren'));
|
||||
} else {
|
||||
this._setNote('analyze', 'fresh', 'aktuell');
|
||||
}
|
||||
|
||||
// --- Faktencheck ---
|
||||
const fc = f.factcheck || {};
|
||||
const fcMeta = document.getElementById('art-fc-meta');
|
||||
if (fcMeta) {
|
||||
fcMeta.textContent = fc.exists
|
||||
? `${fc.facts} Fakten · ${this._fmtStamp(fc.last)}`
|
||||
: 'noch nicht erzeugt';
|
||||
}
|
||||
if (!arts) {
|
||||
this._setNote('factcheck', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
|
||||
} else if (!fc.exists) {
|
||||
this._setNote('factcheck', 'blocked', 'noch nicht erzeugt' + btn('factcheck', 'Erzeugen'));
|
||||
} else if (fc.pending > 0) {
|
||||
this._setNote('factcheck', 'stale',
|
||||
`${fc.pending} neue Artikel seit dem letzten Faktencheck` + btn('factcheck', 'Aktualisieren'));
|
||||
} else {
|
||||
this._setNote('factcheck', 'fresh', 'aktuell');
|
||||
}
|
||||
|
||||
// --- Karte (Geoparsing) --- offen ist hier nicht "neu seit", sondern "noch nicht geprüft"
|
||||
const geo = f.geoparse || {};
|
||||
if (!arts) {
|
||||
this._setNote('geoparse', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
|
||||
} else if (geo.pending > 0) {
|
||||
this._setNote('geoparse', 'stale',
|
||||
`${geo.pending} Artikel noch nicht verortet` + btn('geoparse', 'Verorten'));
|
||||
} else {
|
||||
this._setNote('geoparse', 'fresh', 'alle Artikel verortet');
|
||||
}
|
||||
const setz = (id, wert) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = wert || '';
|
||||
};
|
||||
setz('art-summary-meta', s.exists
|
||||
? `${arts} Artikel ausgewertet, ${this._fmtStamp(s.last)}` : 'noch nicht erzeugt');
|
||||
setz('art-fc-meta', fc.exists
|
||||
? `${fc.facts} Fakten, ${this._fmtStamp(fc.last)}` : 'noch nicht erzeugt');
|
||||
|
||||
// --- Nebenangaben ---
|
||||
const snapMeta = document.getElementById('art-snap-meta');
|
||||
if (snapMeta && !snapMeta.textContent) snapMeta.textContent = f.snapshots ? String(f.snapshots) : '';
|
||||
const tlMeta = document.getElementById('art-tl-meta');
|
||||
if (tlMeta && !tlMeta.textContent && f.events) tlMeta.textContent = `${f.events} Ereignisse`;
|
||||
const snap = document.getElementById('art-snap-meta');
|
||||
if (snap && !snap.textContent) snap.textContent = f.snapshots ? String(f.snapshots) : '';
|
||||
const tl = document.getElementById('art-tl-meta');
|
||||
if (tl && !tl.textContent && f.events) tl.textContent = `${f.events} Ereignisse`;
|
||||
},
|
||||
|
||||
async runStage(stage) {
|
||||
@@ -2082,6 +2456,7 @@ const Studio = {
|
||||
include_telegram: document.getElementById('inc-telegram').checked,
|
||||
include_x: document.getElementById('inc-x').checked,
|
||||
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
||||
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260501h">
|
||||
<link rel="stylesheet" href="/static/css/studio.css?v=20260725a">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260802d">
|
||||
<link rel="stylesheet" href="/static/css/studio.css?v=20260802d">
|
||||
</head>
|
||||
<body>
|
||||
<div class="studio">
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<span class="studio-incident-title" id="incident-title"></span>
|
||||
<span class="studio-type-badge" id="type-badge" style="display:none;"></span>
|
||||
<span id="studio-ai-badge"></span>
|
||||
|
||||
<div class="studio-status" id="live-status">
|
||||
<span class="mini-spinner"></span>
|
||||
@@ -126,8 +127,8 @@
|
||||
<!-- Standardleiste: startet den Auswahlmodus (ohne Haken in der Liste) -->
|
||||
<div class="bulk-bar" id="case-tools">
|
||||
<div class="bulk-actions">
|
||||
<button class="btn-bulk" onclick="Studio.startCaseMode('archive')" title="Mehrere Fälle archivieren oder reaktivieren">Archivieren</button>
|
||||
<button class="btn-bulk danger" onclick="Studio.startCaseMode('delete')" title="Mehrere Fälle löschen">Löschen</button>
|
||||
<button class="btn-bulk" onclick="Studio.startCaseMode('archive')" title="Mehrere Fälle archivieren oder reaktivieren"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span>Archivieren</span></button>
|
||||
<button class="btn-bulk danger" onclick="Studio.startCaseMode('delete')" title="Mehrere Fälle löschen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span>Löschen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,10 +136,10 @@
|
||||
<div class="bulk-bar" id="bulk-bar" hidden>
|
||||
<span class="bulk-count" id="bulk-count"></span>
|
||||
<div class="bulk-actions">
|
||||
<button class="btn-bulk" id="bulk-archive" onclick="Studio.bulkStatus('archived')">Archivieren</button>
|
||||
<button class="btn-bulk" id="bulk-activate" onclick="Studio.bulkStatus('active')">Aktivieren</button>
|
||||
<button class="btn-bulk danger" id="bulk-delete" onclick="Studio.bulkDelete()">Löschen</button>
|
||||
<button class="btn-bulk ghost" onclick="Studio.endCaseMode()" title="Auswahl verwerfen">Abbrechen</button>
|
||||
<button class="btn-bulk" id="bulk-archive" onclick="Studio.bulkStatus('archived')"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span>Archivieren</span></button>
|
||||
<button class="btn-bulk" id="bulk-activate" onclick="Studio.bulkStatus('active')"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg><span>Aktivieren</span></button>
|
||||
<button class="btn-bulk danger" id="bulk-delete" onclick="Studio.bulkDelete()"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span>Löschen</span></button>
|
||||
<button class="btn-bulk ghost" onclick="Studio.endCaseMode()" title="Auswahl verwerfen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg><span>Abbrechen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,12 +153,6 @@
|
||||
<div class="col-actions">
|
||||
<label><input type="checkbox" id="src-select-all" checked onchange="Studio.toggleAllSources(this.checked)"> Alle</label>
|
||||
<span class="spacer"></span>
|
||||
<!-- "Sammeln" steht hier, weil sein Ergebnis hier landet: beide Knoepfe
|
||||
fuellen dieselbe Quellenliste - einmal per Web-Suche, einmal von Hand. -->
|
||||
<button class="studio-btn collect-btn" id="collect-btn" data-stage="collect" type="button" disabled onclick="Studio.runStage('collect')" title="Neue Quellen sammeln (Crawler, Web-Suche, RSS)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="cb-label">Sammeln</span>
|
||||
</button>
|
||||
<button class="studio-btn studio-btn-ghost ingest-add-btn" id="ingest-add-btn" type="button" onclick="Studio.toggleIngest()" title="Dokumente, Bilder, Sprachnachrichten oder Links hinzufügen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Quelle
|
||||
@@ -205,14 +200,6 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="sp-opt" onclick="Studio.startChoice('collect')">
|
||||
<span class="sp-ico"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<span class="sp-text">
|
||||
<span class="sp-name">Nur Quellen sammeln</span>
|
||||
<span class="sp-desc">Erst zusammentragen, was es zum Thema gibt. Analyse und Faktencheck startest du danach selbst, wenn sich das Material lohnt.</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="sp-opt" onclick="Studio.startChoice('ingest')">
|
||||
<span class="sp-ico"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 12 15 15"/></svg></span>
|
||||
<span class="sp-text">
|
||||
@@ -221,13 +208,6 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="sp-opt" onclick="Studio.startChoice('auto')">
|
||||
<span class="sp-ico"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
|
||||
<span class="sp-text">
|
||||
<span class="sp-name">Automatisch aktualisieren</span>
|
||||
<span class="sp-desc" id="sp-auto-desc">Der Fall läuft ab jetzt selbstständig im eingestellten Intervall. Der erste Lauf startet sofort.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +225,124 @@
|
||||
</div>
|
||||
<div class="tab-panel" data-art="timeline"><div id="art-tl-body"></div></div>
|
||||
<div class="tab-panel" data-art="snapshots"><div id="art-snap-body"></div></div>
|
||||
<div class="tab-panel" data-art="settings">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-note" id="set-running-note" hidden>
|
||||
Gerade läuft ein Schritt. Änderungen werden gespeichert, wirken aber erst beim nächsten Lauf danach.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="set-ai-backend">KI-Verarbeitung <span class="info-icon tooltip-below" data-tooltip="Steuert, wo die KI-Verarbeitung dieser Lage stattfindet. Standard: es gilt die Vorgabe der Organisation. Anthropic: heutiger Weg mit eingebauter Websuche. EU: Verarbeitung ueber AWS Bedrock in Frankfurt, Recherche ueber den europaeischen Suchindex staan. Prompts und Suchanfragen verlassen den europaeischen Rechtsraum nicht."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
|
||||
<select id="set-ai-backend" onchange="Studio.settingsAiChanged()">
|
||||
<option value="">Standard (Vorgabe der Organisation)</option>
|
||||
<option value="cli">Anthropic (heutiger Weg)</option>
|
||||
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
|
||||
</select>
|
||||
<div class="form-hint settings-warn" id="set-ai-warn" hidden>
|
||||
Der neue Weg wirkt ab dem nächsten Lauf. Was dieser Fall bisher erzeugt hat, ist auf dem bisherigen Weg entstanden und bleibt unverändert.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="set-title">Titel des Vorfalls</label>
|
||||
<input type="text" id="set-title" required aria-required="true">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="set-description">Beschreibung / Kontext</label>
|
||||
<textarea id="set-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Quellen</label>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-international">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Internationale Quellen einbeziehen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-telegram">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Telegram-Kanäle einbeziehen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-x">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">X (Twitter) einbeziehen</span>
|
||||
</label>
|
||||
<div class="form-hint" id="set-x-hint" hidden>Erst einen X-Zugang hinterlegen (klassische Ansicht → „X-Zugänge").</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sichtbarkeit</label>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-visibility" onchange="Studio.settingsVisibilityHint()">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text" id="set-visibility-text">Öffentlich, für alle Nutzer sichtbar</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="set-refresh-mode">Aktualisierung</label>
|
||||
<select id="set-refresh-mode" onchange="Studio.settingsRefreshToggle()">
|
||||
<option value="manual">Manuell</option>
|
||||
<option value="auto">Automatisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group conditional-field" id="set-interval-field">
|
||||
<label for="set-refresh-value">Intervall</label>
|
||||
<div class="interval-input-group">
|
||||
<input type="number" id="set-refresh-value" min="10" value="15">
|
||||
<select id="set-refresh-unit" onchange="Studio.settingsIntervalMin()">
|
||||
<option value="1" selected>Minuten</option>
|
||||
<option value="60">Stunden</option>
|
||||
<option value="1440">Tage</option>
|
||||
<option value="10080">Wochen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group conditional-field" id="set-starttime-field">
|
||||
<label for="set-refresh-starttime">Erste Aktualisierung um</label>
|
||||
<input type="time" id="set-refresh-starttime" value="07:00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="set-retention">Aufbewahrung (Tage)</label>
|
||||
<input type="number" id="set-retention" min="0" max="999" placeholder="0 = Unbegrenzt">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>E-Mail-Benachrichtigungen</label>
|
||||
<div class="form-hint" style="margin-bottom: 8px;">Per E-Mail benachrichtigen bei</div>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-notify-summary">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Neues Lagebild</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-notify-new-articles">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Neue Artikel</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="set-notify-status-change">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Statusänderung Faktencheck</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-foot">
|
||||
<span class="settings-state" id="set-state"></span>
|
||||
<button type="button" class="btn btn-secondary btn-small" onclick="Studio.loadSettings()">Verwerfen</button>
|
||||
<button type="button" class="btn btn-primary btn-small" id="set-save" onclick="Studio.saveSettings()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel" data-art="export">
|
||||
<div class="export-row">
|
||||
<select id="export-format">
|
||||
@@ -282,118 +380,90 @@
|
||||
<!-- Spalte 3: Studio-Menue (oeffnet Tabs in der Mitte) -->
|
||||
<section class="studio-col studio-col-artifacts">
|
||||
<div class="col-head"><span>Studio</span></div>
|
||||
<!-- Artefakt-Karten: die Karte IST das Ergebnis. Klick oeffnet es in der
|
||||
Mitte, das Symbol rechts rechnet es neu. Die Hinweiszeile darunter
|
||||
meldet selbst, wenn das Ergebnis veraltet ist (siehe /freshness). -->
|
||||
<!-- Drei getrennte Bereiche. Oben wird gearbeitet (ein Startpunkt,
|
||||
darunter die Kette der vier Schritte als Anzeige), in der Mitte
|
||||
werden Ergebnisse angesehen, unten stehen die Fall-Aktionen.
|
||||
Ein Klick auf eine Ansicht oeffnet sie und rechnet nichts neu. -->
|
||||
<div class="col-body studio-menu" id="studio-menu">
|
||||
<!-- Kompletter Lauf orchestriert alles -> steht eigenstaendig ueber den Karten -->
|
||||
<button class="run-all" data-stage="full" disabled onclick="Studio.runStage('full')" title="Alle Schritte nacheinander (Sammeln, Analyse, Faktencheck, Geoparsing)">
|
||||
<span class="ra-icon"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg></span>
|
||||
<span class="ra-label">Kompletter Lauf</span>
|
||||
<span class="ra-state"></span>
|
||||
<!-- Der Lauf. Genau ein Startpunkt, damit nicht zwei Wege zum
|
||||
selben Ziel nebeneinander stehen. Die Beschriftung des Knopfes
|
||||
richtet sich nach dem Zustand des Falls, siehe _renderLauf. -->
|
||||
<div class="lauf-stand" id="lauf-stand">
|
||||
<div class="ls-haupt" id="ls-haupt">Kein Fall geöffnet</div>
|
||||
<div class="ls-neben" id="ls-neben"></div>
|
||||
<button class="ls-btn" id="ls-btn" data-stage="full" disabled
|
||||
onclick="Studio.laufKnopf()">
|
||||
<span class="ls-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg></span>
|
||||
<span class="ls-text">Aktualisieren</span>
|
||||
</button>
|
||||
<div class="ls-hinweis" id="ls-hinweis"></div>
|
||||
</div>
|
||||
|
||||
<!-- Die vier Schritte als Kette. Reine Anzeige, sie starten nichts.
|
||||
Aufklappen erklaert nur, was der Schritt tut. -->
|
||||
<div class="lauf-kette" id="lauf-kette" aria-hidden="true"></div>
|
||||
<div class="lauf-fortschritt" id="lauf-fortschritt"></div>
|
||||
<div class="lauf-schritte" id="lauf-schritte"></div>
|
||||
|
||||
<div class="menu-bereich">Ansehen</div>
|
||||
<button class="studio-menu-item" data-art="summary" onclick="Studio.openTab('summary')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title" id="art-summary-title">Lagebild</span>
|
||||
<span class="mi-meta" id="art-summary-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item mi-unter" data-art="latest" onclick="Studio.openTab('latest')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title" id="art-latest-title">Neueste Entwicklungen</span>
|
||||
<span class="mi-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item mi-unter" data-art="snapshots" onclick="Studio.openTab('snapshots')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Frühere Berichte</span>
|
||||
<span class="mi-meta" id="art-snap-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item" data-art="factcheck" onclick="Studio.openTab('factcheck')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Faktencheck</span>
|
||||
<span class="mi-meta" id="art-fc-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item" data-art="map" onclick="Studio.openTab('map')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Karte</span>
|
||||
<span class="mi-meta" id="map-stats"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item" data-art="timeline" onclick="Studio.openTab('timeline')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Zeitstrahl</span>
|
||||
<span class="mi-meta" id="art-tl-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Lagebild (Erzeuger: Analyse) -->
|
||||
<div class="art-card" data-card="analyze">
|
||||
<div class="art-row">
|
||||
<button class="studio-menu-item art-head" data-art="summary" onclick="Studio.openTab('summary')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title" id="art-summary-title">Lagebild</span>
|
||||
<span class="mi-meta" id="art-summary-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="art-run" data-stage="analyze" disabled onclick="Studio.runStage('analyze')" title="Analyse komplett neu rechnen - der bisherige Bericht bleibt im Verlauf">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="art-note" data-note="analyze"></div>
|
||||
<div class="art-subs">
|
||||
<button class="studio-menu-item art-sub" data-art="latest" onclick="Studio.openTab('latest')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
|
||||
<span class="mi-title" id="art-latest-title">Neueste Entwicklungen</span>
|
||||
<span class="mi-meta"></span>
|
||||
</button>
|
||||
<button class="studio-menu-item art-sub" data-art="snapshots" onclick="Studio.openTab('snapshots')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg></span>
|
||||
<span class="mi-title">Frühere Lageberichte</span>
|
||||
<span class="mi-meta" id="art-snap-meta"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Faktencheck (Verlauf frueherer Laeufe steckt im Faktencheck-Tab selbst) -->
|
||||
<div class="art-card" data-card="factcheck">
|
||||
<div class="art-row">
|
||||
<button class="studio-menu-item art-head" data-art="factcheck" onclick="Studio.openTab('factcheck')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Faktencheck</span>
|
||||
<span class="mi-meta" id="art-fc-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="art-run" data-stage="factcheck" disabled onclick="Studio.runStage('factcheck')" title="Faktencheck komplett neu - der bisherige Stand wird als Lauf archiviert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="art-note" data-note="factcheck"></div>
|
||||
</div>
|
||||
|
||||
<!-- Karte (Erzeuger: Geoparsing) -->
|
||||
<div class="art-card" data-card="geoparse">
|
||||
<div class="art-row">
|
||||
<button class="studio-menu-item art-head" data-art="map" onclick="Studio.openTab('map')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Geografische Verteilung</span>
|
||||
<span class="mi-meta map-stats" id="map-stats"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="art-run" data-stage="geoparse" disabled onclick="Studio.runStage('geoparse')" title="Orte aus noch nicht geprüften Artikeln erkennen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="art-note" data-note="geoparse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Netzwerk: Erzeuger fehlt noch im Backend, daher ohne Ansicht -->
|
||||
<div class="art-card art-card-soon" data-card="network">
|
||||
<div class="art-row">
|
||||
<div class="art-head art-head-static">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Netzwerkanalyse</span>
|
||||
<span class="mi-meta">Akteure und Beziehungen</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="art-run" data-stage="network" disabled title="Baustein folgt">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="art-note" data-note="network"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ereignis-Timeline: hat keinen Erzeuger, entsteht nebenbei -->
|
||||
<div class="art-card" data-card="timeline">
|
||||
<div class="art-row">
|
||||
<button class="studio-menu-item art-head" data-art="timeline" onclick="Studio.openTab('timeline')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Ereignis-Timeline</span>
|
||||
<span class="mi-meta" id="art-tl-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="art-note" data-note="timeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- Export ist eine Ausgabe-Aktion, kein Artefakt -> Fusszeile -->
|
||||
<div class="art-foot">
|
||||
<button class="studio-menu-item art-export" data-art="export" onclick="Studio.openTab('export')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<div class="menu-bereich">Fall</div>
|
||||
<button class="studio-menu-item" data-art="export" onclick="Studio.openTab('export')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Bericht exportieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="mi-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="studio-menu-item" data-art="settings" onclick="Studio.openTab('settings')">
|
||||
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||||
<span class="mi-text">
|
||||
<span class="mi-title">Einstellungen</span>
|
||||
<span class="mi-meta"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -493,6 +563,14 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-ai-backend">KI-Verarbeitung <span class="info-icon tooltip-below" data-tooltip="Steuert, wo die KI-Verarbeitung dieser Lage stattfindet. Standard: es gilt die Vorgabe der Organisation. Anthropic: heutiger Weg mit eingebauter Websuche. EU: Verarbeitung ueber AWS Bedrock in Frankfurt, Recherche ueber den europaeischen Suchindex staan. Prompts und Suchanfragen verlassen den europaeischen Rechtsraum nicht."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
|
||||
<select id="inc-ai-backend">
|
||||
<option value="">Standard (Vorgabe der Organisation)</option>
|
||||
<option value="cli">Anthropic (heutiger Weg)</option>
|
||||
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-refresh-mode">Aktualisierung</label>
|
||||
<select id="inc-refresh-mode" onchange="Studio.incRefreshToggle()">
|
||||
@@ -563,9 +641,9 @@
|
||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||
<script src="/static/js/api.js?v=20260725b"></script>
|
||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||
<script src="/static/js/components.js?v=20260514e"></script>
|
||||
<script src="/static/js/components.js?v=20260802d"></script>
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/ai-disclaimer.js?v=20260725a"></script>
|
||||
<script src="/static/js/studio.js?v=20260725a"></script>
|
||||
<script src="/static/js/studio.js?v=20260802d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
215
tests/test_bedrock_wiederholung.py
Normale Datei
215
tests/test_bedrock_wiederholung.py
Normale Datei
@@ -0,0 +1,215 @@
|
||||
"""Testet die Wiederholung des EU-Modellwegs bei Kapazitaetsengpaessen.
|
||||
|
||||
Hintergrund: Am 01.08.2026 lieferte Bedrock waehrend eines Laufs
|
||||
ServiceUnavailableException (HTTP 503, keine freie Kapazitaet). Botocore gab
|
||||
nach rund fuenf Sekunden auf, die Web-Source-Selektion fiel ersatzlos aus, und
|
||||
im Bericht war davon nichts zu sehen. Gemessen waren es zwei betroffene
|
||||
Aufrufe bei rund 340, also 0,6 Prozent.
|
||||
|
||||
Geprueft werden die vier Zusagen des Umbaus:
|
||||
1. Kapazitaets- und Kontingentfehler werden mit wachsendem Abstand wiederholt.
|
||||
2. Die Wartezeit passt in das Zeitbudget des Aufrufs und in das des Laufs.
|
||||
3. Beide Ursachen werden im Log auseinandergehalten.
|
||||
4. Ein Lauf mit Aussetzern ist im Refresh-Protokoll erkennbar.
|
||||
|
||||
Laeuft ohne Netzzugriff, ohne Kosten und ohne AWS-Zugang: der Converse-Aufruf
|
||||
ist durch ein Testdouble ersetzt, Wartezeiten werden nicht real abgewartet.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_bedrock_wiederholung.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import agents.bedrock_client as bc # noqa: E402
|
||||
from agents.claude_client import ClaudeCliError # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Testdoubles
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeFehler(Exception):
|
||||
"""Botocore-aehnlicher Fehler mit response-Dict."""
|
||||
|
||||
def __init__(self, code):
|
||||
super().__init__(f"{code}: Testfehler")
|
||||
self.response = {"Error": {"Code": code}}
|
||||
|
||||
|
||||
ANTWORT = {
|
||||
"output": {"message": {"content": [{"text": '{"ok": true}'}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 10, "outputTokens": 5},
|
||||
"metrics": {"latencyMs": 1200},
|
||||
}
|
||||
|
||||
aufrufe = {"n": 0}
|
||||
gewartet = []
|
||||
antwortfolge = []
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def converse(self, **kwargs):
|
||||
aufrufe["n"] += 1
|
||||
naechste = antwortfolge.pop(0) if antwortfolge else ANTWORT
|
||||
if isinstance(naechste, Exception):
|
||||
raise naechste
|
||||
return naechste
|
||||
|
||||
|
||||
async def fake_sleep(sekunden):
|
||||
gewartet.append(sekunden)
|
||||
|
||||
|
||||
def neu(folge, budget=None, waits=(5, 15, 30)):
|
||||
"""Setzt Testdoubles und Kontext fuer einen Durchgang."""
|
||||
aufrufe["n"] = 0
|
||||
gewartet.clear()
|
||||
antwortfolge[:] = list(folge)
|
||||
bc.BEDROCK_RETRY_WAITS[:] = list(waits)
|
||||
bc.refresh_kontext_starten()
|
||||
if budget is not None:
|
||||
bc._wartebudget_var.set([budget])
|
||||
|
||||
|
||||
bc._get_client = lambda: FakeClient()
|
||||
bc.asyncio.sleep = fake_sleep
|
||||
|
||||
|
||||
def lauf(timeout=420.0):
|
||||
return asyncio.run(bc.call_bedrock("Testauftrag", timeout=timeout))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Störungsarten auseinanderhalten
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nA. Ursachen unterscheiden")
|
||||
pruefe("A1 503 ist ein Kapazitaetsengpass",
|
||||
bc.stoerungsart(FakeFehler("ServiceUnavailableException")) == "kapazitaet")
|
||||
pruefe("A2 429 ist unser Kontingent",
|
||||
bc.stoerungsart(FakeFehler("ThrottlingException")) == "kontingent")
|
||||
pruefe("A3 Netzabbruch ist eine Verbindungsstoerung",
|
||||
bc.stoerungsart(type("ReadTimeoutError", (Exception,), {})()) == "verbindung")
|
||||
pruefe("A4 Auth-Fehler wird nicht wiederholt",
|
||||
bc.stoerungsart(FakeFehler("AccessDeniedException")) is None)
|
||||
pruefe("A5 beide Ursachen bleiben nach aussen 'rate_limit'",
|
||||
bc._classify_bedrock_error(FakeFehler("ServiceUnavailableException")) == "rate_limit"
|
||||
and bc._classify_bedrock_error(FakeFehler("ThrottlingException")) == "rate_limit")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Wiederholung
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nB. Wiederholung bei Kapazitaetsengpass")
|
||||
neu([FakeFehler("ServiceUnavailableException"), ANTWORT])
|
||||
text, usage = lauf()
|
||||
pruefe("B1 zweiter Versuch liefert das Ergebnis", aufrufe["n"] == 2, aufrufe["n"])
|
||||
pruefe("B2 dazwischen wurde 5 Sekunden gewartet", gewartet == [5], gewartet)
|
||||
pruefe("B3 Verbrauch wird normal zurueckgegeben", usage.input_tokens == 10, usage)
|
||||
|
||||
neu([FakeFehler("ServiceUnavailableException")] * 3 + [ANTWORT])
|
||||
lauf()
|
||||
pruefe("B4 Staffelung 5, 15, 30 Sekunden", gewartet == [5, 15, 30], gewartet)
|
||||
pruefe("B5 vier Versuche insgesamt", aufrufe["n"] == 4, aufrufe["n"])
|
||||
|
||||
neu([FakeFehler("ServiceUnavailableException")] * 5)
|
||||
try:
|
||||
lauf()
|
||||
pruefe("B6 nach der letzten Stufe wird aufgegeben", False, "kein Fehler geworfen")
|
||||
except ClaudeCliError as e:
|
||||
pruefe("B6 nach der letzten Stufe wird aufgegeben", e.error_type == "rate_limit", e.error_type)
|
||||
pruefe("B7 genau vier Versuche, dann Schluss", aufrufe["n"] == 4, aufrufe["n"])
|
||||
|
||||
neu([FakeFehler("AccessDeniedException"), ANTWORT])
|
||||
try:
|
||||
lauf()
|
||||
pruefe("B8 Auth-Fehler wird sofort durchgereicht", False, "kein Fehler geworfen")
|
||||
except ClaudeCliError as e:
|
||||
pruefe("B8 Auth-Fehler wird sofort durchgereicht",
|
||||
e.error_type == "auth_error" and aufrufe["n"] == 1, (e.error_type, aufrufe["n"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. Budgets
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC. Zeitbudget und Wartebudget")
|
||||
# Planungsaufrufe an Haiku haben nur 120 Sekunden. Nach zwei Wartestufen
|
||||
# (5 + 15) ist zu wenig Rest fuer die dritte (30 + 12 Reserve).
|
||||
neu([FakeFehler("ServiceUnavailableException")] * 5)
|
||||
try:
|
||||
lauf(timeout=40.0)
|
||||
except ClaudeCliError:
|
||||
pass
|
||||
pruefe("C1 Wartezeit passt sich dem Zeitbudget des Aufrufs an",
|
||||
gewartet == [5, 15], gewartet)
|
||||
|
||||
neu([FakeFehler("ServiceUnavailableException")] * 5, budget=10.0)
|
||||
try:
|
||||
lauf()
|
||||
except ClaudeCliError:
|
||||
pass
|
||||
pruefe("C2 Wartebudget des Laufs begrenzt die Wiederholung",
|
||||
gewartet == [5], gewartet)
|
||||
|
||||
neu([FakeFehler("ServiceUnavailableException"), ANTWORT], budget=25.0)
|
||||
lauf()
|
||||
pruefe("C3 verbrauchte Wartezeit wird vom Budget abgezogen",
|
||||
abs(bc._budget_rest() - 20.0) < 0.01, bc._budget_rest())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D. Sichtbarkeit
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nD. Sichtbarkeit im Refresh-Protokoll")
|
||||
neu([FakeFehler("ServiceUnavailableException"),
|
||||
FakeFehler("ThrottlingException"), ANTWORT])
|
||||
meldungen = []
|
||||
bc.wartemelder_setzen(lambda art, sek: meldungen.append((art, sek)))
|
||||
lauf()
|
||||
hinweis = bc.stoerungen_zusammenfassen()
|
||||
pruefe("D1 Hinweis nennt beide Ursachen",
|
||||
"Kapazität" in hinweis and "Kontingent" in hinweis, hinweis)
|
||||
pruefe("D2 Hinweis nennt die gesamte Wartezeit", "20s" in hinweis, hinweis)
|
||||
pruefe("D3 Oberflaeche wird waehrend jeder Wartezeit benachrichtigt",
|
||||
meldungen == [("kapazitaet", 5), ("kontingent", 15)], meldungen)
|
||||
pruefe("D4 Hinweis nutzt echte Umlaute",
|
||||
"Kapazitaet" not in hinweis and "ausgeschoepft" not in hinweis, hinweis)
|
||||
|
||||
neu([ANTWORT])
|
||||
bc.wartemelder_setzen(None)
|
||||
lauf()
|
||||
pruefe("D5 stoerungsfreier Lauf erzeugt keinen Hinweis",
|
||||
bc.stoerungen_zusammenfassen() == "", bc.stoerungen_zusammenfassen())
|
||||
pruefe("D6 stoerungsfreier Lauf wartet nicht", gewartet == [], gewartet)
|
||||
|
||||
# Eine kaputte Anzeige darf den Lauf nicht gefaehrden.
|
||||
neu([FakeFehler("ServiceUnavailableException"), ANTWORT])
|
||||
|
||||
|
||||
def _melder_mit_fehler(art, sek):
|
||||
raise RuntimeError("Anzeige kaputt")
|
||||
|
||||
|
||||
bc.wartemelder_setzen(_melder_mit_fehler)
|
||||
text, _ = lauf()
|
||||
pruefe("D7 Fehler in der Anzeige bricht den Lauf nicht ab", text.startswith("{"), text[:40])
|
||||
bc.wartemelder_setzen(None)
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
308
tests/test_eu_belegsuche.py
Normale Datei
308
tests/test_eu_belegsuche.py
Normale Datei
@@ -0,0 +1,308 @@
|
||||
"""Testet die gezielte Belegsuche der EU-Fassung.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten, das planende Modell und die
|
||||
europaeische Suche sind durch Testdoubles ersetzt.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_eu_belegsuche.py
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import agents.eu_researcher as eur # noqa: E402
|
||||
from agents.claude_client import ClaudeUsage # noqa: E402
|
||||
from config import ( # noqa: E402
|
||||
EU_VERIFY_FULLTEXT_CHARS, EU_VERIFY_FULLTEXT_QUERIES, EU_VERIFY_HITS_PER_QUERY,
|
||||
EU_VERIFY_MAX_ITEMS, STAAN_COST_PER_QUERY_USD,
|
||||
)
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
zustand = {"plan_prompt": "", "suchen": []}
|
||||
plan_antwort = {"queries": []}
|
||||
|
||||
|
||||
async def fake_bedrock(prompt, model=None, raw_text=False, timeout=None):
|
||||
zustand["plan_prompt"] = prompt
|
||||
return json.dumps(plan_antwort), ClaudeUsage(input_tokens=10, output_tokens=5, cost_usd=0.002)
|
||||
|
||||
|
||||
treffer_je_anfrage = {}
|
||||
|
||||
|
||||
async def fake_staan(q, market="de-de", extra_snippets=True, max_snippets=3,
|
||||
full_content=False, extra_exclude=None, timeout=None):
|
||||
zustand["suchen"].append({"q": q, "market": market, "full_content": full_content})
|
||||
return treffer_je_anfrage.get(q, [])
|
||||
|
||||
|
||||
def treffer(n, praefix="t", host="example.org"):
|
||||
return [{
|
||||
"title": praefix + " Titel " + str(i),
|
||||
"hostname": host,
|
||||
"url": "https://" + host + "/" + praefix + str(i),
|
||||
"snippet": "Auszug " + str(i),
|
||||
"extra_snippets": ["Zusatz " + str(i)],
|
||||
} for i in range(1, n + 1)]
|
||||
|
||||
|
||||
eur.call_bedrock = fake_bedrock
|
||||
eur.staan_search = fake_staan
|
||||
|
||||
|
||||
def neu():
|
||||
zustand["suchen"].clear()
|
||||
treffer_je_anfrage.clear()
|
||||
|
||||
|
||||
print("\nA) Planung ist punktbezogen und fuehrt die Anfragen aus")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "Ceuta Opferzahl offiziell", "market": "de-de", "for_items": [1]},
|
||||
{"q": "Ceuta border deaths toll", "market": "en-us", "for_items": [1, 2]},
|
||||
{"q": "Sanchez Ceuta declaracion", "market": "es-es", "for_items": [2]},
|
||||
]
|
||||
treffer_je_anfrage["Ceuta Opferzahl offiziell"] = treffer(2, "a")
|
||||
treffer_je_anfrage["Ceuta border deaths toll"] = treffer(2, "b")
|
||||
treffer_je_anfrage["Sanchez Ceuta declaracion"] = treffer(2, "c")
|
||||
_r = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["57 Tote gemeldet", "Sanchez sprach von Angriff"],
|
||||
output_language="Deutsch", max_queries=7,
|
||||
))
|
||||
block, usage, ausgefuehrt = _r.block, _r.usage, _r.queries
|
||||
pruefe("A1 alle geplanten Anfragen ausgefuehrt", len(zustand["suchen"]) == 3, zustand["suchen"])
|
||||
pruefe("A2 ausgefuehrte Anfragen zurueckgemeldet", len(ausgefuehrt) == 3, ausgefuehrt)
|
||||
pruefe("A3 jeder Punkt steht im Planungsauftrag",
|
||||
"57 Tote gemeldet" in zustand["plan_prompt"] and "Sanchez sprach von Angriff" in zustand["plan_prompt"])
|
||||
pruefe("A4 Planungsauftrag verlangt Punktzuordnung", "for_items" in zustand["plan_prompt"])
|
||||
pruefe("A5 Obergrenze steht im Auftrag", "7" in zustand["plan_prompt"])
|
||||
pruefe("A6 alle Treffer nummeriert im Block", block.count("[S") == 6, block.count("[S"))
|
||||
pruefe("A7 Auszuege uebernommen", block.count("Auszug") >= 6, block.count("Auszug"))
|
||||
pruefe("A8 unbekannter Markt faellt auf die Vorgabe zurueck",
|
||||
[s["market"] for s in zustand["suchen"]] == ["de-de", "en-us", "de-de"],
|
||||
[s["market"] for s in zustand["suchen"]])
|
||||
erwartet = round(0.002 + 3 * STAAN_COST_PER_QUERY_USD, 6)
|
||||
pruefe("A9 Suchkosten eingerechnet", round(usage.cost_usd, 6) == erwartet, usage.cost_usd)
|
||||
|
||||
print("\nB) Dieselbe Quelle steht nur einmal im Belegblock")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "anfrage eins", "market": "de-de"},
|
||||
{"q": "anfrage zwei", "market": "de-de"},
|
||||
]
|
||||
treffer_je_anfrage["anfrage eins"] = treffer(3, "x")
|
||||
treffer_je_anfrage["anfrage zwei"] = treffer(3, "x") # identische URLs
|
||||
_rb = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=7,
|
||||
))
|
||||
block_b = _rb.block
|
||||
pruefe("B1 Dubletten entfernt", block_b.count("[S") == 3, block_b.count("[S"))
|
||||
pruefe("B2 Nummerierung bleibt luecklos",
|
||||
all(("[S" + str(i) + "]") in block_b for i in (1, 2, 3)))
|
||||
|
||||
print("\nC) Nachrunde meidet bereits gestellte Anfragen")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "alte anfrage", "market": "de-de"},
|
||||
{"q": "neue anfrage", "market": "de-de"},
|
||||
]
|
||||
treffer_je_anfrage["neue anfrage"] = treffer(2, "n")
|
||||
_rc = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
avoid_queries=["alte anfrage"], label="Nachhak-Suche",
|
||||
))
|
||||
block_c, ausgefuehrt_c = _rc.block, _rc.queries
|
||||
pruefe("C1 bereits gestellte Anfrage wird verworfen",
|
||||
[s["q"] for s in zustand["suchen"]] == ["neue anfrage"], zustand["suchen"])
|
||||
pruefe("C2 Sperrliste steht im Planungsauftrag", "alte anfrage" in zustand["plan_prompt"])
|
||||
pruefe("C3 nur die neue Anfrage gemeldet", ausgefuehrt_c == ["neue anfrage"], ausgefuehrt_c)
|
||||
|
||||
print("\nD) Grenzen werden eingehalten")
|
||||
neu()
|
||||
plan_antwort["queries"] = [{"q": "q" + str(i), "market": "de-de"} for i in range(1, 11)]
|
||||
for i in range(1, 11):
|
||||
treffer_je_anfrage["q" + str(i)] = treffer(20, "y" + str(i))
|
||||
_rd = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=4,
|
||||
))
|
||||
block_d, ausgefuehrt_d = _rd.block, _rd.queries
|
||||
pruefe("D1 hoechstens max_queries Anfragen", len(ausgefuehrt_d) == 4, ausgefuehrt_d)
|
||||
pruefe("D2 Treffer je Anfrage begrenzt",
|
||||
block_d.count("[S") == 4 * EU_VERIFY_HITS_PER_QUERY, block_d.count("[S"))
|
||||
neu()
|
||||
plan_antwort["queries"] = [{"q": "q1", "market": "de-de"}]
|
||||
treffer_je_anfrage["q1"] = treffer(1, "z")
|
||||
asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt " + str(i) for i in range(1, 40)], max_queries=2,
|
||||
))
|
||||
gezaehlt = sum(1 for i in range(1, 40) if ("Punkt " + str(i)) in zustand["plan_prompt"])
|
||||
pruefe("D3 Zahl der Pruefpunkte begrenzt", gezaehlt == EU_VERIFY_MAX_ITEMS, gezaehlt)
|
||||
|
||||
print("\nE) Ausfaelle fuehren nicht zum Abbruch")
|
||||
neu()
|
||||
plan_antwort["queries"] = [{"q": "kaputt", "market": "de-de"}, {"q": "heil", "market": "de-de"}]
|
||||
treffer_je_anfrage["heil"] = treffer(2, "h")
|
||||
|
||||
|
||||
async def staan_mit_fehler(q, **kw):
|
||||
zustand["suchen"].append({"q": q, "market": kw.get("market")})
|
||||
if q == "kaputt":
|
||||
raise eur.StaanError("Suche nicht erreichbar")
|
||||
return treffer_je_anfrage.get(q, [])
|
||||
|
||||
|
||||
eur.staan_search = staan_mit_fehler
|
||||
_re = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
eur.staan_search = fake_staan
|
||||
block_e, usage_e, ausgefuehrt_e = _re.block, _re.usage, _re.queries
|
||||
pruefe("E1 gescheiterte Suche uebersprungen", ausgefuehrt_e == ["heil"], ausgefuehrt_e)
|
||||
pruefe("E2 gefundene Belege bleiben erhalten", block_e.count("[S") == 2, block_e.count("[S"))
|
||||
pruefe("E3 nur bezahlte Suchen berechnet",
|
||||
round(usage_e.cost_usd, 6) == round(0.002 + STAAN_COST_PER_QUERY_USD, 6), usage_e.cost_usd)
|
||||
|
||||
neu()
|
||||
plan_antwort["queries"] = []
|
||||
_rf = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
block_f, ausgefuehrt_f = _rf.block, _rf.queries
|
||||
pruefe("E4 leere Planung liefert leeren Block", block_f == "" and ausgefuehrt_f == [])
|
||||
|
||||
_rg = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=[], max_queries=5,
|
||||
))
|
||||
block_g = _rg.block
|
||||
pruefe("E5 ohne Pruefpunkte keine Suche", block_g == "")
|
||||
|
||||
print("\nF) Gefundene Quellen werden einzeln zurueckgegeben")
|
||||
neu()
|
||||
plan_antwort["queries"] = [{"q": "eine anfrage", "market": "de-de"}]
|
||||
treffer_je_anfrage["eine anfrage"] = treffer(3, "q", host="tagesschau.de")
|
||||
_rh = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
pruefe("F1 jede Quelle einzeln gemeldet", len(_rh.quellen) == 3, len(_rh.quellen))
|
||||
pruefe("F2 Quelle traegt Titel, Herkunft und Adresse",
|
||||
all(q.get("headline") and q.get("source") and q.get("source_url") for q in _rh.quellen),
|
||||
_rh.quellen[:1])
|
||||
pruefe("F3 Adressen stimmen mit dem Block ueberein",
|
||||
all(q["source_url"] in _rh.block for q in _rh.quellen))
|
||||
neu()
|
||||
plan_antwort["queries"] = [{"q": "a", "market": "de-de"}, {"q": "b", "market": "de-de"}]
|
||||
treffer_je_anfrage["a"] = treffer(2, "dup")
|
||||
treffer_je_anfrage["b"] = treffer(2, "dup")
|
||||
_ri = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
pruefe("F4 doppelte Quellen nur einmal gemeldet", len(_ri.quellen) == 2, len(_ri.quellen))
|
||||
|
||||
print("\nG) Der Auftrag verlangt vollstaendige Quellenadressen")
|
||||
from agents.eu_researcher import build_eu_prompt # noqa: E402
|
||||
|
||||
mit = build_eu_prompt("Auftrag", "\n\nBELEGE\n[S1] Titel | host | https://x.y/z")
|
||||
ohne = build_eu_prompt("Auftrag", "")
|
||||
pruefe("G1 Hinweis auf die Adresspflicht vorhanden",
|
||||
"vollständige" in mit and "URL" in mit)
|
||||
pruefe("G2 ohne Belege kein Adresshinweis", "Adresse (die URL" not in ohne)
|
||||
|
||||
print("\nH) Ein Teil der Anfragen ist eine Gegenprobe")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "ceuta opferzahl offiziell", "market": "de-de", "for_items": [1]},
|
||||
{"q": "ceuta opferzahl dementiert kritik", "market": "de-de", "for_items": [1],
|
||||
"gegenprobe": True},
|
||||
]
|
||||
treffer_je_anfrage["ceuta opferzahl offiziell"] = treffer(2, "a")
|
||||
treffer_je_anfrage["ceuta opferzahl dementiert kritik"] = treffer(2, "b")
|
||||
_rj = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["57 Tote gemeldet"], max_queries=7,
|
||||
))
|
||||
def markierte_treffer(block):
|
||||
"""Zaehlt nur Trefferzeilen, nicht die Erklaerung im Kopf."""
|
||||
return sum(1 for z in block.splitlines()
|
||||
if z.startswith("[S") and "[GEGENPROBE]" in z)
|
||||
|
||||
|
||||
pruefe("H1 Auftrag verlangt Gegenproben", "GEGENPROBEN" in zustand["plan_prompt"])
|
||||
pruefe("H2 Gegenprobe-Treffer sind gekennzeichnet",
|
||||
markierte_treffer(_rj.block) == 2, markierte_treffer(_rj.block))
|
||||
pruefe("H3 Kopf erklaert die Gegenprobe", "GEGENPROBE" in _rj.block.split("[S1]")[0])
|
||||
pruefe("H4 stuetzende Treffer bleiben unmarkiert",
|
||||
_rj.block.count("\n[S") == 4, _rj.block.count("\n[S"))
|
||||
|
||||
print("\nI) Belastungsprobe sucht ausschliesslich Gegenproben")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "eine anfrage", "market": "de-de"},
|
||||
{"q": "andere anfrage", "market": "de-de"},
|
||||
]
|
||||
treffer_je_anfrage["eine anfrage"] = treffer(1, "c")
|
||||
treffer_je_anfrage["andere anfrage"] = treffer(1, "d")
|
||||
_rk = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=4,
|
||||
label="Belastungsprobe", nur_gegenproben=True,
|
||||
))
|
||||
pruefe("I1 alle Treffer als Gegenprobe gekennzeichnet",
|
||||
markierte_treffer(_rk.block) == 2, markierte_treffer(_rk.block))
|
||||
pruefe("I2 Auftrag fordert so viele Gegenproben wie Anfragen",
|
||||
"Davon sind 4 Anfragen" in zustand["plan_prompt"])
|
||||
|
||||
print("\nJ) Artikeltexte werden geholt und begrenzt")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "mit text", "market": "de-de", "full_content": True},
|
||||
{"q": "ohne text", "market": "de-de", "full_content": False},
|
||||
]
|
||||
treffer_je_anfrage["mit text"] = [{
|
||||
"title": "Titel mit Text", "hostname": "example.org",
|
||||
"url": "https://example.org/volltext", "snippet": "Auszug",
|
||||
"extra_snippets": [], "full_text": "W" * 5000,
|
||||
}]
|
||||
treffer_je_anfrage["ohne text"] = treffer(1, "e")
|
||||
_rl = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
pruefe("J1 Volltext-Anfrage wurde mit Volltext gestellt",
|
||||
zustand["suchen"][0].get("full_content") is True, zustand["suchen"][0])
|
||||
pruefe("J2 andere Anfrage ohne Volltext",
|
||||
zustand["suchen"][1].get("full_content") is False, zustand["suchen"][1])
|
||||
pruefe("J3 Artikeltext steht im Belegblock", "Artikeltext:" in _rl.block)
|
||||
pruefe("J4 Artikeltext ist begrenzt",
|
||||
_rl.block.count("W") <= EU_VERIFY_FULLTEXT_CHARS + 10, _rl.block.count("W"))
|
||||
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": f"anfrage {i}", "market": "de-de", "full_content": True} for i in range(1, 7)
|
||||
]
|
||||
for i in range(1, 7):
|
||||
treffer_je_anfrage[f"anfrage {i}"] = [{
|
||||
"title": "T", "hostname": "example.org", "url": f"https://example.org/{i}",
|
||||
"snippet": "s", "extra_snippets": [], "full_text": "Text",
|
||||
}]
|
||||
asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=6,
|
||||
))
|
||||
mit_volltext = sum(1 for s in zustand["suchen"] if s.get("full_content"))
|
||||
pruefe("J5 Zahl der Volltext-Anfragen ist gedeckelt",
|
||||
mit_volltext == EU_VERIFY_FULLTEXT_QUERIES, mit_volltext)
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
432
tests/test_eu_factcheck.py
Normale Datei
432
tests/test_eu_factcheck.py
Normale Datei
@@ -0,0 +1,432 @@
|
||||
"""Testet die EU-Nachhak-Schleife im Faktencheck und sichert den CLI-Weg ab.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten, die Belegsuche und der Modellaufruf
|
||||
sind durch Testdoubles ersetzt.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_eu_factcheck.py
|
||||
|
||||
Seit der Belegtiefe-Pruefung (Block 2) traegt eine einzelne Quelle keine
|
||||
Bestaetigung mehr. Die Faelle H und J liefern deshalb Belege aus mehreren
|
||||
unabhaengigen Haeusern, sonst pruefen sie nicht mehr die Zuordnung, sondern
|
||||
nur noch die Mindestzahl.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
from agents.factchecker import FactCheckerAgent # noqa: E402
|
||||
import agents.factchecker as fcmod # noqa: E402
|
||||
import agents.eu_researcher as eur # noqa: E402
|
||||
import agents.claude_client as cc # noqa: E402
|
||||
from agents.claude_client import _ai_backend_var, ClaudeUsage # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
aufrufe = {"such": [], "modell": []}
|
||||
|
||||
|
||||
SUCHQUELLE = {
|
||||
"headline": "Innenministerkonferenz beschliesst Fahrplan",
|
||||
"source": "example.org",
|
||||
"source_url": "https://example.org/a",
|
||||
}
|
||||
|
||||
|
||||
async def fake_plan(*, title, search_items, output_language="Deutsch",
|
||||
max_queries=7, avoid_queries=None, label="Stuetzsuche",
|
||||
nur_gegenproben=False):
|
||||
aufrufe["such"].append({
|
||||
"label": label,
|
||||
"items": list(search_items),
|
||||
"max": max_queries,
|
||||
"avoid": list(avoid_queries or []),
|
||||
"nur_gegenproben": nur_gegenproben,
|
||||
})
|
||||
u = ClaudeUsage(input_tokens=10, output_tokens=5, cost_usd=0.01)
|
||||
block = "\n\nBELEGE [" + label + "]\n[S1] Beleg | example.org | https://example.org/a"
|
||||
return eur.Belegsuche(block, u, [label + "-q1", label + "-q2"], [dict(SUCHQUELLE)])
|
||||
|
||||
|
||||
antworten = []
|
||||
|
||||
|
||||
async def fake_call_claude(prompt, tools="WebSearch,WebFetch", model=None,
|
||||
raw_text=False, timeout=None):
|
||||
aufrufe["modell"].append({"tools": tools, "hat_belege": "BELEGE" in prompt})
|
||||
if not antworten:
|
||||
raise AssertionError("unerwarteter Modellaufruf")
|
||||
return antworten.pop(0), ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
|
||||
|
||||
|
||||
eur.plan_verification_searches = fake_plan
|
||||
fcmod.call_claude = fake_call_claude # CLI-Zweig nutzt das Modul-Symbol
|
||||
cc.call_claude = fake_call_claude # EU-Zweig importiert erst zur Laufzeit
|
||||
|
||||
fc = FactCheckerAgent()
|
||||
ARTS = [
|
||||
{"headline": "Ceuta Grenze Tote gemeldet", "source": "tagesschau",
|
||||
"source_url": "https://tagesschau.de/ceuta", "content_original": "t"},
|
||||
{"headline": "Sanchez spricht ueber Angriff in Ceuta", "source": "elpais",
|
||||
"source_url": "https://elpais.com/ceuta", "content_original": "t"},
|
||||
]
|
||||
|
||||
F_TOTE = "In Ceuta wurden 57 Tote gemeldet"
|
||||
F_SANCHEZ = "Sanchez sprach in Ceuta von einem Angriff"
|
||||
F_GRENZE = "Die Grenze in Ceuta wurde geschlossen"
|
||||
|
||||
|
||||
def neu():
|
||||
aufrufe["such"].clear()
|
||||
aufrufe["modell"].clear()
|
||||
antworten.clear()
|
||||
|
||||
|
||||
def json_fakten(eintraege):
|
||||
teile = []
|
||||
for claim, status, ev in eintraege:
|
||||
teile.append('{"claim": "%s", "status": "%s", "evidence": "%s"}' % (claim, status, ev))
|
||||
return "[" + ", ".join(teile) + "]"
|
||||
|
||||
|
||||
print("\nA) EU-Modus, Nachhak-Runde stuft offene Behauptung hoch")
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "nur eine Quelle"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "confirmed", "laut [S1] https://example.org/a bestaetigt"),
|
||||
]))
|
||||
antworten.append(json_fakten([(F_TOTE, "confirmed", "haelt der Gegenprobe stand")]))
|
||||
facts, usage = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
status = {f.get("claim", "")[:20]: f.get("status") for f in facts}
|
||||
pruefe("A1 Erstsuche plant 7 Anfragen", aufrufe["such"][0]["max"] == 7)
|
||||
pruefe("A2 Erstsuche kennt Titel und Schlagzeilen",
|
||||
len(aufrufe["such"][0]["items"]) == 3, aufrufe["such"][0]["items"])
|
||||
pruefe("A3 Nachhak-Runde und danach Belastungsprobe",
|
||||
[a["label"] for a in aufrufe["such"]]
|
||||
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("A4 Nachhak nur fuer die 2 offenen Punkte",
|
||||
len(aufrufe["such"][1]["items"]) == 2, aufrufe["such"][1]["items"])
|
||||
pruefe("A5 Nachhak meidet die alten Anfragen",
|
||||
aufrufe["such"][1]["avoid"] == ["Stuetzsuche-q1", "Stuetzsuche-q2"],
|
||||
aufrufe["such"][1]["avoid"])
|
||||
pruefe("A6 belegter Fakt hochgestuft", status.get(F_TOTE[:20]) == "confirmed", status)
|
||||
pruefe("A7 unbelegter Fakt bleibt offen", status.get(F_SANCHEZ[:20]) == "unconfirmed", status)
|
||||
pruefe("A8 bestaetigter Fakt unangetastet", status.get(F_GRENZE[:20]) == "confirmed", status)
|
||||
ev = [f["evidence"] for f in facts if f["claim"].startswith("In Ceuta")][0]
|
||||
pruefe("A9 Nachrecherche mit URL in der Evidenz",
|
||||
"Nachrecherche" in ev and "https://example.org/a" in ev, ev[:160])
|
||||
pruefe("A10 kein Faktenverlust", len(facts) == 3, len(facts))
|
||||
pruefe("A11 alle Modellaufrufe ohne Werkzeuge",
|
||||
[m["tools"] for m in aufrufe["modell"]] == [None, None, None], aufrufe["modell"])
|
||||
pruefe("A12 alle Modellaufrufe mit Belegblock",
|
||||
all(m["hat_belege"] for m in aufrufe["modell"]))
|
||||
pruefe("A13 Kosten aller drei Runden aufsummiert",
|
||||
round(usage.cost_usd, 3) == 0.18, usage.cost_usd)
|
||||
|
||||
print("\nB) Ist alles belegt, folgt keine Nachbelegung sondern eine Belastungsprobe")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "confirmed", "belegt"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([(F_TOTE, "confirmed", "haelt der Gegenprobe stand")]))
|
||||
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
pruefe("B1 keine Nachbelegung, sondern Belastungsprobe",
|
||||
[a["label"] for a in aufrufe["such"]] == ["Stuetzsuche", "Belastungsprobe"],
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("B2 Belastungsprobe sucht nur Gegenproben",
|
||||
aufrufe["such"][1]["nur_gegenproben"] is True)
|
||||
pruefe("B3 zwei Modellaufrufe", len(aufrufe["modell"]) == 2, len(aufrufe["modell"]))
|
||||
|
||||
print("\nC) Ein einzelner offener Punkt loest keine Nachbelegung aus")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([(F_GRENZE, "confirmed", "haelt")]))
|
||||
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
pruefe("C1 Schwelle von zwei offenen Punkten greift weiterhin",
|
||||
aufrufe["such"][1]["label"] == "Belastungsprobe",
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("C2 nur der belegte Punkt wird angegriffen",
|
||||
aufrufe["such"][1]["items"] == [F_GRENZE], aufrufe["such"][1]["items"])
|
||||
|
||||
print("\nD) Regression, CLI-Weg unveraendert")
|
||||
_ai_backend_var.set("cli")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
]))
|
||||
facts_d, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
pruefe("D1 CLI macht keine Belegsuche", len(aufrufe["such"]) == 0, aufrufe["such"])
|
||||
pruefe("D2 CLI genau ein Modellaufruf", len(aufrufe["modell"]) == 1, len(aufrufe["modell"]))
|
||||
pruefe("D3 CLI behaelt die WebSearch-Werkzeuge",
|
||||
aufrufe["modell"][0]["tools"] == "WebSearch,WebFetch", aufrufe["modell"])
|
||||
pruefe("D4 CLI bekommt keinen Belegblock", aufrufe["modell"][0]["hat_belege"] is False)
|
||||
pruefe("D5 CLI liefert Fakten", len(facts_d) == 2, len(facts_d))
|
||||
|
||||
print("\nE) EU-Modus, inkrementelle Pruefung")
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
]))
|
||||
antworten.append(json_fakten([
|
||||
(F_SANCHEZ, "confirmed", "[S1] https://example.org/a belegt die Aussage"),
|
||||
]))
|
||||
alt = [{"claim": "Alter offener Punkt aus Ceuta", "status": "unconfirmed", "evidence": "e"}]
|
||||
facts_e, _ = asyncio.run(fc.check_incremental("Ceuta", ARTS, alt, "adhoc", "Deutsch"))
|
||||
pruefe("E1 inkrementell nutzt die Belegsuche", len(aufrufe["such"]) >= 1, aufrufe["such"])
|
||||
pruefe("E2 alte offene Punkte fliessen in die Suche",
|
||||
any("Alter offener Punkt" in i for i in aufrufe["such"][0]["items"]),
|
||||
aufrufe["such"][0]["items"])
|
||||
pruefe("E3 inkrementell hakt nach und prueft danach",
|
||||
[a["label"] for a in aufrufe["such"]]
|
||||
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("E4 inkrementelle Hochstufung greift",
|
||||
any(f["claim"].startswith("Sanchez") and f["status"] == "confirmed" for f in facts_e),
|
||||
[(f["claim"][:25], f["status"]) for f in facts_e])
|
||||
|
||||
print("\nF) Regression, CLI-Weg inkrementell unveraendert")
|
||||
_ai_backend_var.set("cli")
|
||||
neu()
|
||||
antworten.append(json_fakten([(F_TOTE, "unconfirmed", "unklar")]))
|
||||
asyncio.run(fc.check_incremental("Ceuta", ARTS, alt, "adhoc", "Deutsch"))
|
||||
pruefe("F1 CLI inkrementell ohne Belegsuche", len(aufrufe["such"]) == 0, aufrufe["such"])
|
||||
pruefe("F2 CLI inkrementell mit Werkzeugen",
|
||||
aufrufe["modell"][0]["tools"] == "WebSearch,WebFetch", aufrufe["modell"])
|
||||
|
||||
print("\nG) EU-Modus, Nachhak-Runde faellt aus, Fakten bleiben erhalten")
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
]))
|
||||
antworten.append("Das ist kein JSON, sondern Fliesstext.")
|
||||
facts_g, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
pruefe("G1 Fakten ueberleben eine unbrauchbare Nachhak-Antwort", len(facts_g) == 2, len(facts_g))
|
||||
pruefe("G2 Status bleibt offen",
|
||||
all(f["status"] in ("unconfirmed", "unverified") for f in facts_g),
|
||||
[(f["claim"][:25], f["status"]) for f in facts_g])
|
||||
|
||||
print("\nH) EU-Modus, Gruppenpruefung nutzt die kleinere Suchmenge")
|
||||
from config import EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES # noqa: E402
|
||||
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
]))
|
||||
antworten.append(json_fakten([(F_TOTE, "confirmed",
|
||||
"[S1] https://example.org/a und [S2] https://beispiel.de/b belegen")]))
|
||||
facts_h, _ = asyncio.run(fc._eu_pruefen(
|
||||
"Auftrag", title="Ceuta (Opfer)", articles=None, output_language="Deutsch",
|
||||
search_items=[F_TOTE, F_SANCHEZ], incident_type="adhoc",
|
||||
max_queries=EU_VERIFY_GROUP_QUERIES,
|
||||
followup_queries=EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
|
||||
))
|
||||
pruefe("H1 Gruppe plant die kleinere Suchmenge",
|
||||
aufrufe["such"][0]["max"] == EU_VERIFY_GROUP_QUERIES, aufrufe["such"][0]["max"])
|
||||
pruefe("H2 Gruppe nutzt die kleinere Menge auch in den Folgerunden",
|
||||
[a["max"] for a in aufrufe["such"]]
|
||||
== [EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
|
||||
EU_VERIFY_GROUP_FOLLOWUP_QUERIES],
|
||||
[a["max"] for a in aufrufe["such"]])
|
||||
pruefe("H3 Gruppe stuft belegten Fakt hoch",
|
||||
any(f["claim"].startswith("In Ceuta") and f["status"] == "confirmed" for f in facts_h),
|
||||
[(f["claim"][:25], f["status"]) for f in facts_h])
|
||||
|
||||
print("\nI) Nachhaken abschaltbar")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
]))
|
||||
asyncio.run(fc._eu_pruefen(
|
||||
"Auftrag", title="Ceuta", articles=None, output_language="Deutsch",
|
||||
search_items=[F_TOTE], incident_type="adhoc", followup_queries=0,
|
||||
))
|
||||
pruefe("I1 followup_queries=0 unterbindet die Nachrunde", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
|
||||
|
||||
print("\nJ) Suchbelege zaehlen bei der Quellenzuordnung")
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
# Behauptung, die zu KEINEM gespeicherten Artikel passt, wohl aber zur
|
||||
# Suchquelle. Vor der Korrektur wurde so ein Fakt herabgestuft.
|
||||
NUR_SUCHE = "Die Innenministerkonferenz beschliesst einen Fahrplan"
|
||||
antworten.append(json_fakten([
|
||||
(NUR_SUCHE, "established",
|
||||
"belegt durch die Suche: https://example.org/a, https://beispiel.de/b, https://muster.at/c"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
facts_j, _ = asyncio.run(fc.check("Ceuta", ARTS, "research", "Deutsch"))
|
||||
treffer_j = [f for f in facts_j if f["claim"].startswith("Die Innenministerkonferenz")]
|
||||
pruefe("J1 Fakt allein aus der Suche bleibt belegt",
|
||||
treffer_j and treffer_j[0]["status"] == "established",
|
||||
[(f["claim"][:35], f["status"]) for f in facts_j])
|
||||
pruefe("J2 Adresse der Suchquelle steht in der Evidenz",
|
||||
treffer_j and "https://example.org/a" in treffer_j[0]["evidence"],
|
||||
treffer_j[0]["evidence"][:160] if treffer_j else "")
|
||||
|
||||
print("\nK) Ohne Suchbelege bleibt die Zuordnung wie bisher")
|
||||
_ai_backend_var.set("cli")
|
||||
neu()
|
||||
antworten.append(json_fakten([(NUR_SUCHE, "established", "belegt")]))
|
||||
facts_k, _ = asyncio.run(fc.check("Ceuta", ARTS, "research", "Deutsch"))
|
||||
pruefe("K1 CLI stuft ohne zuordenbare Quelle weiterhin herab",
|
||||
facts_k and facts_k[0]["status"] != "established",
|
||||
[(f["claim"][:35], f["status"]) for f in facts_k])
|
||||
|
||||
print("\nL) Belastungsprobe greift, wenn alles bestaetigt aussieht")
|
||||
_ai_backend_var.set("bedrock")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "confirmed", "belegt"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
(F_SANCHEZ, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "[S1] https://example.org/a nennt nur eine Schaetzung"),
|
||||
]))
|
||||
facts_l, usage_l = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
status_l = {f["claim"][:20]: f["status"] for f in facts_l}
|
||||
pruefe("L1 zweite Runde ist eine Belastungsprobe",
|
||||
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Belastungsprobe",
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("L2 Belastungsprobe sucht ausschliesslich Gegenproben",
|
||||
aufrufe["such"][1]["nur_gegenproben"] is True)
|
||||
pruefe("L3 Belastungsprobe meidet die schon gestellten Anfragen",
|
||||
aufrufe["such"][1]["avoid"] == ["Stuetzsuche-q1", "Stuetzsuche-q2"],
|
||||
aufrufe["such"][1]["avoid"])
|
||||
pruefe("L4 nicht haltbare Bestaetigung wird zurueckgenommen",
|
||||
status_l.get(F_TOTE[:20]) == "unconfirmed", status_l)
|
||||
pruefe("L5 haltbare Bestaetigungen bleiben",
|
||||
status_l.get(F_GRENZE[:20]) == "confirmed" and status_l.get(F_SANCHEZ[:20]) == "confirmed",
|
||||
status_l)
|
||||
pruefe("L6 kein Faktenverlust", len(facts_l) == 3, len(facts_l))
|
||||
pruefe("L7 Begruendung vermerkt die Belastungsprobe",
|
||||
any("Belastungsprobe" in (f.get("evidence") or "") for f in facts_l),
|
||||
[f.get("evidence", "")[:70] for f in facts_l])
|
||||
|
||||
print("\nM) Die Belastungsprobe kann nur zurueckstufen")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
(F_SANCHEZ, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([
|
||||
(F_GRENZE, "confirmed", "haelt"),
|
||||
(F_SANCHEZ, "established", "sogar noch besser belegt"),
|
||||
]))
|
||||
facts_m, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
status_m = {f["claim"][:20]: f["status"] for f in facts_m}
|
||||
pruefe("M1 Belastungsprobe laeuft auch bei einem offenen Punkt",
|
||||
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Belastungsprobe",
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("M2 keine Hochstufung durch die Belastungsprobe",
|
||||
status_m.get(F_SANCHEZ[:20]) == "confirmed", status_m)
|
||||
pruefe("M3 offener Punkt bleibt offen", status_m.get(F_TOTE[:20]) == "unconfirmed", status_m)
|
||||
|
||||
print("\nN1) Nach der Nachbelegung folgt die Belastungsprobe")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
# Die Nachbelegung hebt beide offenen Punkte hoch, danach ist nichts mehr offen.
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "confirmed", "[S1] https://example.org/a belegt es"),
|
||||
(F_SANCHEZ, "confirmed", "[S1] https://example.org/a belegt es"),
|
||||
]))
|
||||
# Die Belastungsprobe nimmt eine der Bestaetigungen wieder zurueck.
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "[S1] nennt nur eine Schaetzung"),
|
||||
]))
|
||||
facts_n1, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
status_n1 = {f["claim"][:20]: f["status"] for f in facts_n1}
|
||||
pruefe("N1a beide Runden laufen nacheinander",
|
||||
[a["label"] for a in aufrufe["such"]]
|
||||
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
|
||||
[a["label"] for a in aufrufe["such"]])
|
||||
pruefe("N1b Belastungsprobe meidet auch die Anfragen der Nachbelegung",
|
||||
"Nachhak-Suche-q1" in aufrufe["such"][2]["avoid"], aufrufe["such"][2]["avoid"])
|
||||
pruefe("N1c nicht haltbare Nachbelegung wird zurueckgenommen",
|
||||
status_n1.get(F_TOTE[:20]) == "unconfirmed", status_n1)
|
||||
pruefe("N1d haltbare Nachbelegung bleibt",
|
||||
status_n1.get(F_SANCHEZ[:20]) == "confirmed", status_n1)
|
||||
|
||||
print("\nN) Widerspruch in der Nachhak-Runde stuft zurueck")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "unconfirmed", "unklar"),
|
||||
(F_SANCHEZ, "unconfirmed", "unklar"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "contradicted", "[S1] https://example.org/a nennt eine andere Zahl"),
|
||||
]))
|
||||
facts_n, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
status_n = {f["claim"][:20]: f["status"] for f in facts_n}
|
||||
pruefe("N1 Nachhak-Runde nimmt den Widerspruch auf",
|
||||
status_n.get(F_TOTE[:20]) == "contradicted", status_n)
|
||||
|
||||
print("\nO) Faellt die zweite Runde aus, bleibt der erste Durchgang erhalten")
|
||||
neu()
|
||||
antworten.append(json_fakten([
|
||||
(F_TOTE, "confirmed", "belegt"),
|
||||
(F_GRENZE, "confirmed", "belegt"),
|
||||
]))
|
||||
|
||||
|
||||
async def plan_kaputt(**kw):
|
||||
raise RuntimeError("Suche nicht erreichbar")
|
||||
|
||||
|
||||
eur.plan_verification_searches = fake_plan
|
||||
_alt_plan = eur.plan_verification_searches
|
||||
|
||||
|
||||
async def plan_erst_gut_dann_kaputt(**kw):
|
||||
if kw.get("label") == "Belastungsprobe":
|
||||
raise RuntimeError("Suche nicht erreichbar")
|
||||
return await fake_plan(**kw)
|
||||
|
||||
|
||||
eur.plan_verification_searches = plan_erst_gut_dann_kaputt
|
||||
facts_o, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
|
||||
eur.plan_verification_searches = fake_plan
|
||||
pruefe("O1 Fakten ueberleben den Ausfall", len(facts_o) == 2, len(facts_o))
|
||||
pruefe("O2 Bewertungen bleiben unveraendert",
|
||||
all(f["status"] == "confirmed" for f in facts_o),
|
||||
[(f["claim"][:25], f["status"]) for f in facts_o])
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
276
tests/test_faktencheck_belegtiefe.py
Normale Datei
276
tests/test_faktencheck_belegtiefe.py
Normale Datei
@@ -0,0 +1,276 @@
|
||||
"""Testet die Belegtiefe des Faktenchecks.
|
||||
|
||||
Grundlage sind die drei Fehler, die der Vergleich der Ceuta-Berichte vom
|
||||
01.08.2026 in der EU-Fassung zeigte:
|
||||
|
||||
1. Eine sachlich zutreffende Zahlenspanne stand als WIDERLEGT, weil der Status
|
||||
"contradicted" im Auftrag Quellendivergenz meint, in der Anzeige aber als
|
||||
Widerlegung ausgegeben wurde.
|
||||
2. "Der Ausloeser ist weiterhin unklar" wurde von der Nachhak-Runde auf
|
||||
BESTAETIGT hochgestuft, weil "developing" die niedrigste Prioritaet hat.
|
||||
3. Eine Behauptung stand als BESTAETIGT mit einer Quelle, deren Evidenz
|
||||
woertlich mit "Nur durch Al Jazeera berichtet" begann.
|
||||
|
||||
Dazu kommt die Zaehlung: sechs France24-Treffer sind ein Medienhaus, nicht
|
||||
sechs Belege.
|
||||
|
||||
Laeuft ohne Netzzugriff, ohne Kosten und ohne Datenbank.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_faktencheck_belegtiefe.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import agents.factchecker as fc # noqa: E402
|
||||
from agents.claude_client import ClaudeUsage # noqa: E402
|
||||
import report_generator as rg # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Zaehlung nach Medienhaus
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nA. Belege zaehlen")
|
||||
france24 = [
|
||||
"https://www.france24.com/en/ceuta-spain-slams-selfish-eu-partners",
|
||||
"https://www.france24.com/en/video/20260801-ceuta-enclave-almost-back-to-normal-1",
|
||||
"https://www.france24.com/en/video/20260801-ceuta-most-migrants-have-returned-1",
|
||||
"https://www.france24.com/en/ceuta-2026-dwarfs-2021-migration-crisis",
|
||||
]
|
||||
pruefe("A1 vier France24-Fundstellen sind ein Haus",
|
||||
fc.zaehle_medienhaeuser(france24) == 1, fc.zaehle_medienhaeuser(france24))
|
||||
gemischt = france24 + [
|
||||
"https://www.tagesschau.de/ausland/europa/ceuta-100.html",
|
||||
"https://www.theguardian.com/world/2026/aug/01/spain-ceuta",
|
||||
"https://www.bbc.co.uk/news/articles/abc",
|
||||
]
|
||||
pruefe("A2 drei weitere Haeuser ergeben vier",
|
||||
fc.zaehle_medienhaeuser(gemischt) == 4, fc.zaehle_medienhaeuser(gemischt))
|
||||
redirects = [
|
||||
"https://news.google.com/rss/articles/AAA?oc=5",
|
||||
"https://news.google.com/rss/articles/BBB?oc=5",
|
||||
]
|
||||
pruefe("A3 Weiterleitungen zaehlen zusammen als ein Beleg",
|
||||
fc.zaehle_medienhaeuser(redirects) == 1, fc.zaehle_medienhaeuser(redirects))
|
||||
# Der gemeldete Fall aus Lage 57: 11 Adressen, davon vier Weiterleitungen,
|
||||
# ergaben eine ausgewiesene Belegzahl von 10 bei tatsaechlich 7 Haeusern.
|
||||
lage57 = [
|
||||
"https://news.google.com/rss/articles/A?oc=5",
|
||||
"https://news.google.com/rss/articles/B?oc=5",
|
||||
"https://news.google.com/rss/articles/C?oc=5",
|
||||
"https://news.google.com/rss/articles/D?oc=5",
|
||||
"https://www.tagesschau.de/a.html", "https://www.tagesschau.de/b.html",
|
||||
"https://www.france24.com/x", "https://www.theguardian.com/y",
|
||||
"https://www.ft.com/z", "https://www.aljazeera.com/q", "https://www.bbc.co.uk/r",
|
||||
]
|
||||
pruefe("A3b elf Adressen mit vier Weiterleitungen ergeben sieben Belege",
|
||||
fc.zaehle_medienhaeuser(lage57) == 7, fc.zaehle_medienhaeuser(lage57))
|
||||
pruefe("A4 Satzzeichen am Adressende stoeren nicht",
|
||||
fc.zaehle_medienhaeuser(["https://www.zeit.de/a.html)."]) == 1)
|
||||
pruefe("A5 leere Liste ergibt null", fc.zaehle_medienhaeuser([]) == 0)
|
||||
|
||||
# Regressionsschutz: das alte Muster zaehlte nur das Schema und lieferte
|
||||
# deshalb fuer jede Evidenz 1 oder 2, egal wie viele Quellen darin standen.
|
||||
evidenz = ("Bestätigt durch: tagesschau (https://www.tagesschau.de/a.html), "
|
||||
"Guardian (https://www.theguardian.com/b), BBC (https://www.bbc.co.uk/c)")
|
||||
pruefe("A6 Adressen werden vollstaendig erkannt, nicht nur das Schema",
|
||||
fc.zaehle_medienhaeuser(fc._URL_RE.findall(evidenz)) == 3,
|
||||
fc._URL_RE.findall(evidenz))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Nachbedingung: Status gegen Beleglage
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nB. Belegtiefe pruefen")
|
||||
einzelquelle = {
|
||||
"claim": "Spaniens Außenminister versicherte, die Integrität sei garantiert",
|
||||
"status": "confirmed",
|
||||
"sources_count": 1,
|
||||
"evidence": "Nur durch Al Jazeera berichtet (https://www.aljazeera.com/news/x)",
|
||||
}
|
||||
fc.pruefe_belegtiefe(einzelquelle)
|
||||
pruefe("B1 bestaetigt mit einer Quelle wird herabgestuft",
|
||||
einzelquelle["status"] == "unconfirmed", einzelquelle["status"])
|
||||
|
||||
zwei = {"claim": "x", "status": "confirmed", "sources_count": 2, "evidence": ""}
|
||||
fc.pruefe_belegtiefe(zwei)
|
||||
pruefe("B2 zwei Haeuser tragen eine Bestaetigung", zwei["status"] == "confirmed")
|
||||
|
||||
research_knapp = {"claim": "x", "status": "established", "sources_count": 2, "evidence": ""}
|
||||
fc.pruefe_belegtiefe(research_knapp)
|
||||
pruefe("B3 gesichert verlangt drei Haeuser", research_knapp["status"] == "unverified",
|
||||
research_knapp["status"])
|
||||
|
||||
primaer = {
|
||||
"claim": "Weber kritisiert Spanien",
|
||||
"status": "confirmed",
|
||||
"sources_count": 1,
|
||||
"evidence_type": "primary",
|
||||
"evidence": "O-Ton im ZDF-Interview (https://www.zdfheute.de/politik/x.html)",
|
||||
}
|
||||
fc.pruefe_belegtiefe(primaer)
|
||||
pruefe("B4 Primaerbeleg traegt allein", primaer["status"] == "confirmed", primaer["status"])
|
||||
|
||||
ohne_zahl = {
|
||||
"claim": "x", "status": "confirmed",
|
||||
"evidence": "a (https://www.zeit.de/1), b (https://www.faz.net/2)",
|
||||
}
|
||||
fc.pruefe_belegtiefe(ohne_zahl)
|
||||
pruefe("B5 fehlende Belegzahl wird aus der Evidenz bestimmt",
|
||||
ohne_zahl["sources_count"] == 2 and ohne_zahl["status"] == "confirmed", ohne_zahl)
|
||||
|
||||
widerspruch = {"claim": "x", "status": "contradicted", "sources_count": 1, "evidence": ""}
|
||||
fc.pruefe_belegtiefe(widerspruch)
|
||||
pruefe("B6 Widerspruchs-Status wird nicht angetastet", widerspruch["status"] == "contradicted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. Anzeige: Widerspruch ist keine Widerlegung
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC. Statusbezeichnungen")
|
||||
labels = rg._fc_labels("de")
|
||||
pruefe("C1 contradicted heisst nicht mehr Widerlegt",
|
||||
labels["contradicted"] == "Widersprüchlich", labels["contradicted"])
|
||||
pruefe("C2 Widerlegt ist dem eigenen Status vorbehalten",
|
||||
labels["false"] == "Widerlegt", labels.get("false"))
|
||||
pruefe("C3 englische Fassung getrennt",
|
||||
rg._fc_labels("en")["contradicted"] == "Conflicting", rg._fc_labels("en")["contradicted"])
|
||||
pruefe("C4 false hat eine Prioritaet wie andere belegte Befunde",
|
||||
fc.STATUS_PRIORITY.get("false") == 4, fc.STATUS_PRIORITY.get("false"))
|
||||
|
||||
aufbereitet = rg._prepare_fact_checks([
|
||||
{"claim": "a", "status": "contradicted"}, {"claim": "b", "status": "false"},
|
||||
], "de")
|
||||
pruefe("C5 Bericht beschriftet beide Status getrennt",
|
||||
[f["status_label"] for f in aufbereitet] == ["Widersprüchlich", "Widerlegt"],
|
||||
[f["status_label"] for f in aufbereitet])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D. Bewertungsregeln liegen jedem Auftrag bei
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nD. Auftragsregeln")
|
||||
regeln = fc.bewertungsregeln()
|
||||
for stichwort, name in (
|
||||
("Medienhäuser", "D1 Unabhaengigkeit ueber Medienhaeuser"),
|
||||
("news.google.com", "D2 Weiterleitungen ausgeschlossen"),
|
||||
("Erhebungsstellen", "D3 Zahlenspannen sind kein Widerspruch"),
|
||||
("primary", "D4 Primaerbeleg definiert"),
|
||||
("Kenntnisstand", "D5 keine Aussagen ueber den Kenntnisstand"),
|
||||
):
|
||||
pruefe(name, stichwort in regeln)
|
||||
umschreibungen = ["fuer", "muessen", "koennen", "haeuser", "zaehlt", "Widerspruechlich",
|
||||
"Auftraege", "gehoeren", "unabhaengig", "genuegt"]
|
||||
gefunden = [u for u in umschreibungen if u.lower() in regeln.lower()]
|
||||
pruefe("D6 Regeln nutzen echte Umlaute statt Umschreibungen", not gefunden, gefunden)
|
||||
|
||||
# Drei Befunde aus dem Bericht zu Lage 60 vom 02.08.2026: eine Behauptung trug
|
||||
# das Wort "freiwillig", obwohl das eigene Lagebild von behoerdlicher
|
||||
# Aufforderung und Abschiebungsandrohung berichtete; eine korrigierte Todeszahl
|
||||
# stand als Spanne "57 bis 67"; und alle zehn Behauptungen waren bestaetigt.
|
||||
for stichwort, name in (
|
||||
("freiwillig", "D7 wertende Zusaetze nur bei Beleg"),
|
||||
("Zeitreihe", "D8 korrigierte Werte sind eine Zeitreihe"),
|
||||
("strittige Punkte", "D9 strittige Punkte gehoeren in den Faktencheck"),
|
||||
("jede\n Behauptung bestätigt wird, sagt dem Leser nichts",
|
||||
"D10 lauter Bestaetigungen sind kein Ergebnis"),
|
||||
):
|
||||
pruefe(name, stichwort in regeln)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E. Nachhak-Runde
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nE. Nachhak-Runde")
|
||||
|
||||
|
||||
class FakeSuche:
|
||||
def __init__(self):
|
||||
self.block = "\n[S1] Beleg | zeit.de | https://www.zeit.de/a"
|
||||
self.usage = ClaudeUsage()
|
||||
self.queries = ["q"]
|
||||
self.quellen = []
|
||||
|
||||
|
||||
async def fake_plan(**kwargs):
|
||||
return FakeSuche()
|
||||
|
||||
|
||||
antwort = {"text": "[]"}
|
||||
|
||||
|
||||
async def fake_call(prompt, **kwargs):
|
||||
return antwort["text"], ClaudeUsage()
|
||||
|
||||
|
||||
import agents.eu_researcher as eur # noqa: E402
|
||||
import agents.claude_client as cc # noqa: E402
|
||||
eur.plan_verification_searches = fake_plan
|
||||
cc.call_claude = fake_call
|
||||
|
||||
agent = fc.FactCheckerAgent()
|
||||
|
||||
# Fall 1: Die Nachrunde will eine Unklarheit bestaetigen.
|
||||
unklar = {
|
||||
"claim": "Der Auslöser des massenhaften Grenzübertritts ist weiterhin unklar",
|
||||
"status": "developing", "sources_count": 4,
|
||||
"evidence": "Mehrere Quellen berichten über Unklarheit (https://www.bbc.co.uk/a)",
|
||||
}
|
||||
antwort["text"] = ('[{"claim": "Der Auslöser des massenhaften Grenzübertritts ist weiterhin unklar",'
|
||||
' "status": "confirmed", "evidence": "Vier Quellen nennen die Unklarheit"}]')
|
||||
facts, _, _ = asyncio.run(agent._eu_nachhaken(
|
||||
facts=[unklar], offene=[unklar], title="Ceuta", output_language="Deutsch",
|
||||
incident_type="adhoc", avoid_queries=[],
|
||||
))
|
||||
pruefe("E1 eine Unklarheit wird nicht bestaetigt", facts[0]["status"] == "developing", facts[0]["status"])
|
||||
|
||||
# Fall 2: echte Hochstufung, aber nur eine Quelle im Beleg
|
||||
duenn = {
|
||||
"claim": "Spaniens Außenminister versicherte, die Integrität sei garantiert",
|
||||
"status": "unconfirmed", "sources_count": 1,
|
||||
"evidence": "Nur durch Al Jazeera berichtet (https://www.aljazeera.com/a)",
|
||||
}
|
||||
antwort["text"] = ('[{"claim": "Spaniens Außenminister versicherte, die Integrität sei garantiert",'
|
||||
' "status": "confirmed", "evidence": "Auch hier (https://www.aljazeera.com/b)"}]')
|
||||
facts, _, _ = asyncio.run(agent._eu_nachhaken(
|
||||
facts=[duenn], offene=[duenn], title="Ceuta", output_language="Deutsch",
|
||||
incident_type="adhoc", avoid_queries=[],
|
||||
))
|
||||
pruefe("E2 zweiter Treffer desselben Hauses traegt keine Bestaetigung",
|
||||
facts[0]["status"] == "unconfirmed", facts[0]["status"])
|
||||
|
||||
# Fall 3: gedeckte Hochstufung durch ein zweites Haus
|
||||
tragfaehig = {
|
||||
"claim": "Italien führt Grenzkontrollen für Flüge und Schiffe aus Spanien ein",
|
||||
"status": "unconfirmed", "sources_count": 1,
|
||||
"evidence": "Kurier (https://www.kurier.at/a)",
|
||||
}
|
||||
antwort["text"] = ('[{"claim": "Italien führt Grenzkontrollen für Flüge und Schiffe aus Spanien ein",'
|
||||
' "status": "confirmed", "evidence": "Bestätigt (https://www.ansa.it/b)"}]')
|
||||
facts, _, _ = asyncio.run(agent._eu_nachhaken(
|
||||
facts=[tragfaehig], offene=[tragfaehig], title="Ceuta", output_language="Deutsch",
|
||||
incident_type="adhoc", avoid_queries=[],
|
||||
))
|
||||
pruefe("E3 zweites unabhaengiges Haus stuft hoch",
|
||||
facts[0]["status"] == "confirmed", facts[0]["status"])
|
||||
pruefe("E4 Belegzahl folgt der neuen Evidenz",
|
||||
facts[0]["sources_count"] == 2, facts[0]["sources_count"])
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
125
tests/test_faktenspanne.py
Normale Datei
125
tests/test_faktenspanne.py
Normale Datei
@@ -0,0 +1,125 @@
|
||||
"""Testet, dass die Zahl der Faktenaussagen mit dem Material waechst.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten, der Modellaufruf ist ersetzt.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_faktenspanne.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import agents.factchecker as fcmod # noqa: E402
|
||||
import agents.claude_client as cc # noqa: E402
|
||||
from agents.factchecker import ( # noqa: E402
|
||||
FactCheckerAgent, faktenspanne, FACTCHECK_PROMPT_TEMPLATE,
|
||||
RESEARCH_FACTCHECK_PROMPT_TEMPLATE, INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE,
|
||||
INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE, TRIAGE_PROMPT_TEMPLATE,
|
||||
)
|
||||
from agents.claude_client import _ai_backend_var, ClaudeUsage # noqa: E402
|
||||
from config import FAKTEN_MAX, FAKTEN_MIN, FAKTEN_NEU_MAX, FAKTEN_NEU_MIN # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
print("\nA) Die Spanne waechst mit der Zahl der Meldungen")
|
||||
klein = faktenspanne(16)
|
||||
mittel = faktenspanne(53)
|
||||
gross = faktenspanne(88)
|
||||
sehr_gross = faktenspanne(300)
|
||||
pruefe("A1 wenige Meldungen ergeben die alte Spanne", klein == (5, 10), klein)
|
||||
pruefe("A2 mehr Meldungen ergeben mehr Fakten", gross[1] > klein[1], (klein, gross))
|
||||
pruefe("A3 die Reihenfolge stimmt",
|
||||
klein[1] <= mittel[1] <= gross[1] <= sehr_gross[1],
|
||||
(klein, mittel, gross, sehr_gross))
|
||||
pruefe("A4 Obergrenze wird eingehalten", sehr_gross[1] == FAKTEN_MAX, sehr_gross)
|
||||
pruefe("A5 Untergrenze wird eingehalten", faktenspanne(0)[0] == FAKTEN_MIN, faktenspanne(0))
|
||||
pruefe("A6 Untergrenze liegt nie ueber der Obergrenze",
|
||||
all(faktenspanne(n)[0] <= faktenspanne(n)[1] for n in range(0, 400, 7)))
|
||||
pruefe("A7 negative Eingabe bricht nicht", faktenspanne(-5) == (5, 10), faktenspanne(-5))
|
||||
|
||||
print("\nB) Fuer die Aktualisierung gilt ein engerer Korridor")
|
||||
neu_klein = faktenspanne(8, neu=True)
|
||||
neu_gross = faktenspanne(200, neu=True)
|
||||
pruefe("B1 wenige neue Meldungen ergeben die alte Spanne",
|
||||
neu_klein == (FAKTEN_NEU_MIN, FAKTEN_NEU_MIN * 2), neu_klein)
|
||||
pruefe("B2 Obergrenze der Aktualisierung greift", neu_gross[1] == FAKTEN_NEU_MAX, neu_gross)
|
||||
pruefe("B3 Aktualisierung bleibt unter der Erstpruefung",
|
||||
faktenspanne(200, neu=True)[1] < faktenspanne(200)[1])
|
||||
|
||||
print("\nC) Die Auftraege enthalten Platzhalter statt fester Zahlen")
|
||||
for name, vorlage in (
|
||||
("Erstpruefung adhoc", FACTCHECK_PROMPT_TEMPLATE),
|
||||
("Erstpruefung Recherche", RESEARCH_FACTCHECK_PROMPT_TEMPLATE),
|
||||
("Aktualisierung adhoc", INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE),
|
||||
("Aktualisierung Recherche", INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE),
|
||||
("Triage", TRIAGE_PROMPT_TEMPLATE)):
|
||||
pruefe(f"C {name} nutzt den Richtwert",
|
||||
"{fakten_min}" in vorlage and "{fakten_max}" in vorlage)
|
||||
pruefe(f"C {name} ohne feste Vorgabe",
|
||||
"5-10" not in vorlage and "3-5 " not in vorlage and "(3-5" not in vorlage)
|
||||
|
||||
pruefe("C Richtwert ist ausdruecklich keine Zielvorgabe",
|
||||
"Richtwert und keine" in FACTCHECK_PROMPT_TEMPLATE
|
||||
and "Zielvorgabe" in FACTCHECK_PROMPT_TEMPLATE)
|
||||
pruefe("C Abweichen nach oben und unten ist erlaubt",
|
||||
"weniger" in FACTCHECK_PROMPT_TEMPLATE and "mehr" in FACTCHECK_PROMPT_TEMPLATE)
|
||||
|
||||
print("\nD) Der berechnete Wert landet im Auftrag")
|
||||
letzter = {"prompt": ""}
|
||||
|
||||
|
||||
async def fake_call_claude(prompt, tools="WebSearch,WebFetch", model=None,
|
||||
raw_text=False, timeout=None):
|
||||
letzter["prompt"] = prompt
|
||||
return "[]", ClaudeUsage(input_tokens=10, output_tokens=5, cost_usd=0.01)
|
||||
|
||||
|
||||
fcmod.call_claude = fake_call_claude
|
||||
cc.call_claude = fake_call_claude
|
||||
_ai_backend_var.set("cli")
|
||||
fc = FactCheckerAgent()
|
||||
|
||||
|
||||
def meldungen(n):
|
||||
return [{"headline": f"Meldung {i}", "source": "tagesschau",
|
||||
"source_url": f"https://tagesschau.de/{i}", "content_original": "t"}
|
||||
for i in range(n)]
|
||||
|
||||
|
||||
asyncio.run(fc.check("Lage", meldungen(16), "adhoc", "Deutsch"))
|
||||
u, o = faktenspanne(16)
|
||||
pruefe("D1 kleine Lage traegt ihren Richtwert",
|
||||
f"etwa {u} bis {o} Aussagen" in letzter["prompt"],
|
||||
[z for z in letzter["prompt"].splitlines() if "Richtwert" in z][:1])
|
||||
|
||||
asyncio.run(fc.check("Lage", meldungen(88), "adhoc", "Deutsch"))
|
||||
u2, o2 = faktenspanne(88)
|
||||
pruefe("D2 grosse Lage traegt einen groesseren Richtwert",
|
||||
f"etwa {u2} bis {o2} Aussagen" in letzter["prompt"] and o2 > o,
|
||||
[z for z in letzter["prompt"].splitlines() if "Richtwert" in z][:1])
|
||||
|
||||
asyncio.run(fc.check_incremental(
|
||||
"Lage", meldungen(30),
|
||||
[{"claim": "Ein bestehender Punkt", "status": "confirmed", "evidence": "e"}],
|
||||
"adhoc", "Deutsch"))
|
||||
u3, o3 = faktenspanne(30, neu=True)
|
||||
pruefe("D3 Aktualisierung nutzt den engeren Korridor",
|
||||
f"etwa\n {u3} bis {o3}" in letzter["prompt"] or f"{u3} bis {o3}" in letzter["prompt"],
|
||||
[z for z in letzter["prompt"].splitlines() if "Richtwert" in z][:1])
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
359
tests/test_falleinstellungen.js
Normale Datei
359
tests/test_falleinstellungen.js
Normale Datei
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Testet den Einstellungen-Reiter des Studios ohne Browser.
|
||||
*
|
||||
* Die Methoden werden aus studio.js herausgeloest und gegen ein schlankes
|
||||
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
|
||||
*
|
||||
* Aufruf aus dem Projektstamm:
|
||||
* node tests/test_falleinstellungen.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
function pruefe(name, bedingung, extra) {
|
||||
if (bedingung) {
|
||||
ok++;
|
||||
console.log(' OK ' + name);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(' FEHL ' + name + ' '
|
||||
+ (extra === undefined ? '' : JSON.stringify(extra)));
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM-Double ------------------------------------------------------------
|
||||
function element(id) {
|
||||
const klassen = new Set();
|
||||
return {
|
||||
id: id,
|
||||
value: '',
|
||||
checked: false,
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
min: 0,
|
||||
_fokus: 0,
|
||||
focus() { this._fokus++; },
|
||||
classList: {
|
||||
toggle(name, an) { if (an) klassen.add(name); else klassen.delete(name); },
|
||||
contains(name) { return klassen.has(name); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FELDER = [
|
||||
'set-title', 'set-description', 'set-international', 'set-telegram', 'set-x',
|
||||
'set-x-hint', 'set-visibility', 'set-visibility-text', 'set-ai-backend',
|
||||
'set-ai-warn', 'set-refresh-mode', 'set-refresh-value', 'set-refresh-unit',
|
||||
'set-interval-field', 'set-starttime-field', 'set-refresh-starttime',
|
||||
'set-retention', 'set-notify-summary', 'set-notify-new-articles',
|
||||
'set-notify-status-change', 'set-save', 'set-state', 'set-running-note',
|
||||
];
|
||||
|
||||
const elemente = {};
|
||||
const meldungen = [];
|
||||
const aufrufe = { update: [], abo: [] };
|
||||
let aboAntwort = null;
|
||||
let aboWirft = false;
|
||||
let updateWirft = false;
|
||||
let xKonten = [{ active: true }];
|
||||
|
||||
const umgebung = {
|
||||
document: { getElementById: (id) => elemente[id] || null },
|
||||
UI: { showToast: (text, art) => meldungen.push({ text, art }) },
|
||||
API: {
|
||||
getSubscription: async () => {
|
||||
if (aboWirft) throw new Error('kein Abo');
|
||||
return aboAntwort;
|
||||
},
|
||||
updateIncident: async (id, daten) => {
|
||||
aufrufe.update.push({ id, daten });
|
||||
if (updateWirft) throw new Error('keine Berechtigung');
|
||||
return Object.assign({ id }, daten);
|
||||
},
|
||||
updateSubscription: async (id, daten) => { aufrufe.abo.push({ id, daten }); },
|
||||
listXAccounts: async () => xKonten,
|
||||
},
|
||||
console: console,
|
||||
};
|
||||
umgebung.window = umgebung;
|
||||
|
||||
// --- Die Methoden aus studio.js herausloesen -------------------------------
|
||||
const quelle = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'static', 'js', 'studio.js'), 'utf8');
|
||||
const von = quelle.indexOf(' // === Einstellungen des Falls ===');
|
||||
const bis = quelle.indexOf(' _tabTitle(key) {', von);
|
||||
if (von === -1 || bis === -1) {
|
||||
console.log('FEHLER: Einstellungen-Block in studio.js nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kontext = vm.createContext(umgebung);
|
||||
vm.runInContext('var Studio = {\n' + quelle.slice(von, bis) + '\n'
|
||||
+ ' incident: null, incidents: [], _runningStage: null,\n'
|
||||
+ ' _kopf: 0, _liste: 0,\n'
|
||||
+ ' renderHeader() { this._kopf++; },\n'
|
||||
+ ' renderCases() { this._liste++; },\n'
|
||||
+ '};', kontext);
|
||||
const Studio = kontext.Studio;
|
||||
|
||||
const LAGE = {
|
||||
id: 7,
|
||||
title: 'Ceuta',
|
||||
description: 'Grenzsituation',
|
||||
type: 'adhoc',
|
||||
status: 'active',
|
||||
visibility: 'public',
|
||||
international_sources: true,
|
||||
include_telegram: false,
|
||||
include_x: false,
|
||||
ai_backend: 'bedrock',
|
||||
refresh_mode: 'auto',
|
||||
refresh_interval: 1440,
|
||||
refresh_start_time: '06:30',
|
||||
retention_days: 30,
|
||||
};
|
||||
|
||||
function neu(ueber) {
|
||||
for (const id of FELDER) elemente[id] = element(id);
|
||||
Studio.incident = Object.assign({}, LAGE, ueber || {});
|
||||
Studio.incidents = [Object.assign({}, Studio.incident)];
|
||||
Studio._runningStage = null;
|
||||
Studio._settingsAlt = null;
|
||||
Studio._settingsAboAlt = null;
|
||||
Studio._kopf = 0;
|
||||
Studio._liste = 0;
|
||||
aufrufe.update.length = 0;
|
||||
aufrufe.abo.length = 0;
|
||||
meldungen.length = 0;
|
||||
aboAntwort = null;
|
||||
aboWirft = false;
|
||||
updateWirft = false;
|
||||
xKonten = [{ active: true }];
|
||||
}
|
||||
|
||||
const g = (id) => elemente[id];
|
||||
|
||||
async function main() {
|
||||
console.log('\nA) Das Formular wird aus dem Fall gefüllt');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
pruefe('A1 Titel', g('set-title').value === 'Ceuta');
|
||||
pruefe('A2 Beschreibung', g('set-description').value === 'Grenzsituation');
|
||||
pruefe('A3 internationale Quellen an', g('set-international').checked === true);
|
||||
pruefe('A4 Telegram aus', g('set-telegram').checked === false);
|
||||
pruefe('A5 öffentlich', g('set-visibility').checked === true);
|
||||
pruefe('A6 KI-Weg steht auf EU', g('set-ai-backend').value === 'bedrock');
|
||||
pruefe('A7 Aufbewahrung', String(g('set-retention').value) === '30');
|
||||
pruefe('A8 Startzeit', g('set-refresh-starttime').value === '06:30');
|
||||
|
||||
console.log('\nB) Das Intervall wird in der größten glatten Einheit gezeigt');
|
||||
pruefe('B1 1440 Minuten werden zu 1 Tag',
|
||||
g('set-refresh-unit').value === '1440' && String(g('set-refresh-value').value) === '1',
|
||||
[g('set-refresh-unit').value, g('set-refresh-value').value]);
|
||||
neu({ refresh_interval: 90 });
|
||||
await Studio.loadSettings();
|
||||
pruefe('B2 90 Minuten bleiben Minuten',
|
||||
g('set-refresh-unit').value === '1' && String(g('set-refresh-value').value) === '90',
|
||||
[g('set-refresh-unit').value, g('set-refresh-value').value]);
|
||||
neu({ refresh_interval: 120 });
|
||||
await Studio.loadSettings();
|
||||
pruefe('B3 120 Minuten werden zu 2 Stunden',
|
||||
g('set-refresh-unit').value === '60' && String(g('set-refresh-value').value) === '2');
|
||||
neu({ refresh_interval: 10080 });
|
||||
await Studio.loadSettings();
|
||||
pruefe('B4 10080 Minuten werden zu 1 Woche',
|
||||
g('set-refresh-unit').value === '10080' && String(g('set-refresh-value').value) === '1');
|
||||
|
||||
console.log('\nC) Intervall und Startzeit nur bei automatischer Aktualisierung');
|
||||
neu({ refresh_mode: 'manual' });
|
||||
await Studio.loadSettings();
|
||||
pruefe('C1 bei manuell verborgen',
|
||||
g('set-interval-field').classList.contains('visible') === false
|
||||
&& g('set-starttime-field').classList.contains('visible') === false);
|
||||
g('set-refresh-mode').value = 'auto';
|
||||
Studio.settingsRefreshToggle();
|
||||
pruefe('C2 bei automatisch sichtbar',
|
||||
g('set-interval-field').classList.contains('visible') === true
|
||||
&& g('set-starttime-field').classList.contains('visible') === true);
|
||||
|
||||
console.log('\nD) Gespeichert wird nur, was sich geändert hat');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
await Studio.saveSettings();
|
||||
pruefe('D1 ohne Änderung kein Aufruf', aufrufe.update.length === 0, aufrufe.update);
|
||||
pruefe('D2 der Zustand sagt es', g('set-state').textContent === 'Nichts geändert',
|
||||
g('set-state').textContent);
|
||||
|
||||
g('set-title').value = 'Ceuta und Melilla';
|
||||
await Studio.saveSettings();
|
||||
pruefe('D3 genau ein Aufruf', aufrufe.update.length === 1);
|
||||
pruefe('D4 nur der Titel im Patch',
|
||||
JSON.stringify(Object.keys(aufrufe.update[0].daten)) === '["title"]',
|
||||
aufrufe.update[0].daten);
|
||||
pruefe('D5 richtige Lage', aufrufe.update[0].id === 7);
|
||||
pruefe('D6 Liste und Kopfzeile neu gezeichnet',
|
||||
Studio._kopf === 1 && Studio._liste === 1);
|
||||
pruefe('D7 Rückmeldung an den Nutzer',
|
||||
meldungen.length === 1 && meldungen[0].art === 'success', meldungen);
|
||||
|
||||
await Studio.saveSettings();
|
||||
pruefe('D8 direkt danach ist wieder nichts offen', aufrufe.update.length === 1,
|
||||
aufrufe.update);
|
||||
|
||||
console.log('\nE) Der KI-Weg lässt sich umstellen');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
g('set-ai-backend').value = 'cli';
|
||||
await Studio.saveSettings();
|
||||
pruefe('E1 nur der KI-Weg im Patch',
|
||||
JSON.stringify(aufrufe.update[0].daten) === '{"ai_backend":"cli"}',
|
||||
aufrufe.update[0].daten);
|
||||
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
g('set-ai-backend').value = '';
|
||||
await Studio.saveSettings();
|
||||
pruefe('E2 Zurücksetzen auf die Org-Vorgabe schickt den Leerstring',
|
||||
aufrufe.update.length === 1 && aufrufe.update[0].daten.ai_backend === '',
|
||||
aufrufe.update[0] && aufrufe.update[0].daten);
|
||||
|
||||
console.log('\nF) Der Warnhinweis folgt der Änderung');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
pruefe('F1 zunächst verborgen', g('set-ai-warn').hidden === true);
|
||||
g('set-ai-backend').value = 'cli';
|
||||
Studio.settingsAiChanged();
|
||||
pruefe('F2 nach der Umstellung sichtbar', g('set-ai-warn').hidden === false);
|
||||
g('set-ai-backend').value = 'bedrock';
|
||||
Studio.settingsAiChanged();
|
||||
pruefe('F3 zurückgestellt wieder verborgen', g('set-ai-warn').hidden === true);
|
||||
g('set-ai-backend').value = 'cli';
|
||||
Studio.settingsAiChanged();
|
||||
await Studio.saveSettings();
|
||||
pruefe('F4 nach dem Speichern ist der neue Weg der Normalfall',
|
||||
g('set-ai-warn').hidden === true);
|
||||
|
||||
console.log('\nG) Der Hinweis auf einen laufenden Schritt');
|
||||
neu();
|
||||
Studio._runningStage = 'collect';
|
||||
await Studio.loadSettings();
|
||||
pruefe('G1 bei laufendem Schritt sichtbar', g('set-running-note').hidden === false);
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
pruefe('G2 sonst verborgen', g('set-running-note').hidden === true);
|
||||
|
||||
console.log('\nH) E-Mail-Benachrichtigungen');
|
||||
neu();
|
||||
aboAntwort = { notify_email_summary: true, notify_email_new_articles: false,
|
||||
notify_email_status_change: true };
|
||||
await Studio.loadSettings();
|
||||
pruefe('H1 Stand wird geladen',
|
||||
g('set-notify-summary').checked === true
|
||||
&& g('set-notify-new-articles').checked === false
|
||||
&& g('set-notify-status-change').checked === true);
|
||||
await Studio.saveSettings();
|
||||
pruefe('H2 ohne Änderung kein Abo-Aufruf', aufrufe.abo.length === 0);
|
||||
g('set-notify-new-articles').checked = true;
|
||||
await Studio.saveSettings();
|
||||
pruefe('H3 Änderung wird geschickt', aufrufe.abo.length === 1);
|
||||
pruefe('H4 alle drei Werte im Aufruf',
|
||||
aufrufe.abo[0].daten.notify_email_new_articles === true
|
||||
&& aufrufe.abo[0].daten.notify_email_summary === true,
|
||||
aufrufe.abo[0].daten);
|
||||
pruefe('H5 die Lage selbst wurde nicht angefasst', aufrufe.update.length === 0);
|
||||
|
||||
neu();
|
||||
aboWirft = true;
|
||||
await Studio.loadSettings();
|
||||
pruefe('H6 ohne Abo bleiben die Haken aus',
|
||||
g('set-notify-summary').checked === false
|
||||
&& g('set-notify-status-change').checked === false);
|
||||
|
||||
console.log('\nI) Randfälle');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
g('set-title').value = ' ';
|
||||
await Studio.saveSettings();
|
||||
pruefe('I1 ohne Titel wird nicht gespeichert', aufrufe.update.length === 0);
|
||||
pruefe('I2 Fehlermeldung erscheint',
|
||||
meldungen.length === 1 && meldungen[0].art === 'error', meldungen);
|
||||
pruefe('I3 das Titelfeld bekommt den Fokus', g('set-title')._fokus === 1);
|
||||
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
updateWirft = true;
|
||||
g('set-title').value = 'Anderer Titel';
|
||||
await Studio.saveSettings();
|
||||
pruefe('I4 Fehler beim Speichern wird gemeldet',
|
||||
meldungen.some(m => m.art === 'error'), meldungen);
|
||||
pruefe('I5 der Zustand sagt es auch', g('set-state').textContent === 'Nicht gespeichert',
|
||||
g('set-state').textContent);
|
||||
pruefe('I6 der Knopf ist wieder bedienbar', g('set-save').disabled === false);
|
||||
|
||||
neu();
|
||||
Studio.incident = null;
|
||||
await Studio.loadSettings();
|
||||
await Studio.saveSettings();
|
||||
pruefe('I7 ohne offenen Fall passiert nichts', aufrufe.update.length === 0);
|
||||
|
||||
console.log('\nJ) Der X-Schalter hängt an den hinterlegten Zugängen');
|
||||
neu();
|
||||
xKonten = [];
|
||||
await Studio.loadSettings();
|
||||
pruefe('J1 ohne Zugang gesperrt', g('set-x').disabled === true);
|
||||
pruefe('J2 mit Hinweis', g('set-x-hint').hidden === false);
|
||||
neu({ include_x: true });
|
||||
xKonten = [];
|
||||
await Studio.loadSettings();
|
||||
pruefe('J3 nutzt der Fall X bereits, bleibt er bedienbar', g('set-x').disabled === false);
|
||||
neu();
|
||||
xKonten = [{ active: true }];
|
||||
await Studio.loadSettings();
|
||||
pruefe('J4 mit Zugang frei und ohne Hinweis',
|
||||
g('set-x').disabled === false && g('set-x-hint').hidden === true);
|
||||
|
||||
console.log('\nK) Die Sichtbarkeit beschriftet sich selbst');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
pruefe('K1 öffentlich', g('set-visibility-text').textContent.startsWith('Öffentlich'),
|
||||
g('set-visibility-text').textContent);
|
||||
g('set-visibility').checked = false;
|
||||
Studio.settingsVisibilityHint();
|
||||
pruefe('K2 privat', g('set-visibility-text').textContent.startsWith('Privat'),
|
||||
g('set-visibility-text').textContent);
|
||||
|
||||
console.log('\nL) Das Kennzeichen des KI-Wegs');
|
||||
pruefe('L1 EU', Studio._aiTag({ ai_backend: 'bedrock' }, 'x').includes('>EU<'));
|
||||
pruefe('L2 EU trägt die Klasse', Studio._aiTag({ ai_backend: 'bedrock' }, 'x').includes('x ai-eu'));
|
||||
pruefe('L3 Anthropic', Studio._aiTag({ ai_backend: 'cli' }, 'x').includes('>Anthropic<'));
|
||||
pruefe('L4 ohne Angabe bleibt es leer', Studio._aiTag({ ai_backend: null }, 'x') === '');
|
||||
pruefe('L5 auch ohne Lage', Studio._aiTag(null, 'x') === '');
|
||||
pruefe('L6 unbekannter Wert erzeugt nichts',
|
||||
Studio._aiTag({ ai_backend: 'irgendwas' }, 'x') === '');
|
||||
|
||||
console.log('\nM) Die Untergrenze des Intervalls');
|
||||
neu();
|
||||
await Studio.loadSettings();
|
||||
g('set-refresh-unit').value = '1';
|
||||
g('set-refresh-value').value = '3';
|
||||
Studio.settingsIntervalMin();
|
||||
pruefe('M1 in Minuten sind 10 das Minimum', String(g('set-refresh-value').value) === '10',
|
||||
g('set-refresh-value').value);
|
||||
g('set-refresh-unit').value = '60';
|
||||
g('set-refresh-value').value = '1';
|
||||
Studio.settingsIntervalMin();
|
||||
pruefe('M2 in Stunden ist 1 erlaubt', String(g('set-refresh-value').value) === '1');
|
||||
|
||||
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
337
tests/test_lauf.js
Normale Datei
337
tests/test_lauf.js
Normale Datei
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Testet die Lauf-Anzeige der Bausteinspalte ohne Browser.
|
||||
*
|
||||
* Die Methoden werden aus studio.js herausgeloest und gegen ein schlankes
|
||||
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
|
||||
*
|
||||
* Aufruf aus dem Projektstamm:
|
||||
* node tests/test_lauf.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
function pruefe(name, bedingung, extra) {
|
||||
if (bedingung) {
|
||||
ok++;
|
||||
console.log(' OK ' + name);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(' FEHL ' + name + ' '
|
||||
+ (extra === undefined ? '' : JSON.stringify(extra)));
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM-Double ------------------------------------------------------------
|
||||
function element(id) {
|
||||
const klassen = new Set();
|
||||
const el = {
|
||||
id: id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
classList: {
|
||||
add: (n) => klassen.add(n),
|
||||
remove: (n) => klassen.delete(n),
|
||||
toggle: (n, an) => { if (an) klassen.add(n); else klassen.delete(n); },
|
||||
contains: (n) => klassen.has(n),
|
||||
},
|
||||
style: {},
|
||||
_kinder: {},
|
||||
querySelector(sel) {
|
||||
const k = sel.replace('.', '');
|
||||
if (!this._kinder[k]) this._kinder[k] = element(k);
|
||||
return this._kinder[k];
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const IDS = ['lauf-stand', 'ls-haupt', 'ls-neben', 'ls-btn', 'ls-hinweis',
|
||||
'lauf-kette', 'lauf-fortschritt', 'lauf-schritte', 'start-panel',
|
||||
'art-summary-meta', 'art-fc-meta', 'art-snap-meta', 'art-tl-meta',
|
||||
'studio-cols', 'studio-empty'];
|
||||
const elemente = {};
|
||||
const meldungen = [];
|
||||
const aufrufe = [];
|
||||
|
||||
const umgebung = {
|
||||
document: {
|
||||
getElementById: (id) => elemente[id] || null,
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
UI: {
|
||||
showToast: (t, a) => meldungen.push({ t, a }),
|
||||
escape: (s) => String(s === undefined || s === null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
},
|
||||
CSS: { escape: (s) => s },
|
||||
localStorage: { _d: {}, setItem(k, v) { this._d[k] = v; }, getItem(k) { return this._d[k]; } },
|
||||
console: console,
|
||||
};
|
||||
umgebung.window = umgebung;
|
||||
|
||||
// --- Den Lauf-Block aus studio.js herausloesen -----------------------------
|
||||
const quelle = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'static', 'js', 'studio.js'), 'utf8');
|
||||
const von = quelle.indexOf(' // === Der Lauf (Spalte 3) ===');
|
||||
const bis = quelle.indexOf(' async runStage(stage) {', von);
|
||||
if (von === -1 || bis === -1) {
|
||||
console.log('FEHLER: Lauf-Block in studio.js nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kontext = vm.createContext(umgebung);
|
||||
const Studio = vm.runInContext(
|
||||
'var Studio = {\n' + quelle.slice(von, bis) + '\n'
|
||||
+ ' incident: null, fresh: null, _runningStage: null,\n'
|
||||
+ ' async runStage(s) { this._letzterStart = s; },\n'
|
||||
// _showEmpty steht ausserhalb des herausgeloesten Blocks, deshalb hier
|
||||
// wortgleich nachgebildet.
|
||||
+ ' _showEmpty(on) {\n'
|
||||
+ ' const c = document.getElementById("studio-cols");\n'
|
||||
+ ' if (c) c.classList.toggle("idle", on);\n'
|
||||
+ ' document.getElementById("studio-empty").style.display = on ? "" : "none";\n'
|
||||
+ ' if (on) { this.incident = null; this.fresh = null; this._renderLauf(); }\n'
|
||||
+ ' },\n'
|
||||
+ '};\nStudio;', kontext);
|
||||
|
||||
const LAGE = { id: 7, title: 'Ceuta', type: 'adhoc' };
|
||||
|
||||
// Vier Zustaende, wie sie im Betrieb wirklich vorkommen
|
||||
const FRISCH = {
|
||||
leer: { articles: 0, summary: {}, factcheck: {}, geoparse: {} },
|
||||
veraltet: { articles: 85, summary: { exists: true, last: '2026-08-02 18:10', pending: 3 },
|
||||
factcheck: { exists: true, last: '2026-08-02 16:00', facts: 19, pending: 3 },
|
||||
geoparse: { pending: 0 }, snapshots: 4, events: 12 },
|
||||
aktuell: { articles: 85, summary: { exists: true, last: '2026-08-02 18:40', pending: 0 },
|
||||
factcheck: { exists: true, last: '2026-08-02 18:41', facts: 19, pending: 0 },
|
||||
geoparse: { pending: 0 }, snapshots: 4, events: 12 },
|
||||
halb: { articles: 85, summary: { exists: false }, factcheck: { exists: false },
|
||||
geoparse: { pending: 85 } },
|
||||
};
|
||||
|
||||
function neu(zustand, ueber) {
|
||||
for (const id of IDS) elemente[id] = element(id);
|
||||
Studio.incident = Object.assign({}, LAGE, ueber || {});
|
||||
Studio.fresh = FRISCH[zustand] ? JSON.parse(JSON.stringify(FRISCH[zustand])) : null;
|
||||
Studio._runningStage = null;
|
||||
Studio._schrittOffen = null;
|
||||
Studio._letzterStart = null;
|
||||
meldungen.length = 0;
|
||||
aufrufe.length = 0;
|
||||
}
|
||||
|
||||
const g = (id) => elemente[id];
|
||||
const knopfText = () => g('ls-btn').querySelector('.ls-text').textContent;
|
||||
|
||||
async function main() {
|
||||
console.log('\nA) Der Startknopf richtet sich nach dem Zustand');
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('A1 leerer Fall lädt zum Start ein', knopfText() === 'Lauf starten', knopfText());
|
||||
pruefe('A2 der Stand sagt warum', g('ls-haupt').textContent.includes('Noch keine Artikel'),
|
||||
g('ls-haupt').textContent);
|
||||
pruefe('A3 der Kasten ist hervorgehoben', g('lauf-stand').classList.contains('betont'));
|
||||
pruefe('A4 mit einem Hinweis, was danach kommt',
|
||||
g('ls-hinweis').textContent.length > 10 && g('ls-hinweis').hidden === false);
|
||||
|
||||
neu('leer', { type: 'research' });
|
||||
Studio._renderLauf();
|
||||
pruefe('A5 bei einer Recherche heißt es Recherche starten',
|
||||
knopfText() === 'Recherche starten', knopfText());
|
||||
|
||||
neu('veraltet');
|
||||
Studio._renderLauf();
|
||||
pruefe('A6 bei neuen Artikeln heißt es aktualisieren',
|
||||
knopfText() === 'Jetzt aktualisieren', knopfText());
|
||||
pruefe('A7 der Stand nennt die Zahl',
|
||||
g('ls-haupt').textContent.includes('3 neue Artikel'), g('ls-haupt').textContent);
|
||||
pruefe('A8 und warnt', g('ls-haupt').classList.contains('warn'));
|
||||
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('A9 wenn alles frisch ist heißt es erneut durchlaufen',
|
||||
knopfText() === 'Erneut durchlaufen', knopfText());
|
||||
pruefe('A10 der Kasten ist dann ruhig', !g('lauf-stand').classList.contains('betont'));
|
||||
pruefe('A11 und der Hinweis verschwindet', g('ls-hinweis').hidden === true);
|
||||
pruefe('A12 der Bestand steht daneben',
|
||||
g('ls-neben').textContent === '85 Artikel im Bestand', g('ls-neben').textContent);
|
||||
|
||||
console.log('\nB) Während eines Laufs');
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'full';
|
||||
Studio._renderLauf();
|
||||
pruefe('B1 der Knopf bricht ab', knopfText() === 'Abbrechen', knopfText());
|
||||
pruefe('B2 er ist rot abgesetzt', g('ls-btn').classList.contains('abbrechen'));
|
||||
pruefe('B3 er bleibt klickbar', g('ls-btn').disabled === false);
|
||||
pruefe('B4 der Stand sagt, was läuft',
|
||||
g('ls-haupt').textContent.includes('Kompletter Lauf'), g('ls-haupt').textContent);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'analyze';
|
||||
Studio._renderLauf();
|
||||
pruefe('B5 ein nicht abbrechbarer Schritt sperrt den Knopf', g('ls-btn').disabled === true);
|
||||
pruefe('B6 und wird nicht als Abbruch angeboten',
|
||||
!g('ls-btn').classList.contains('abbrechen'));
|
||||
pruefe('B7 der Stand nennt den Schritt beim Ergebnisnamen',
|
||||
g('ls-haupt').textContent.includes('Lagebild schreiben'), g('ls-haupt').textContent);
|
||||
|
||||
console.log('\nC) Der Knopf trifft den richtigen Schritt');
|
||||
neu('veraltet');
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C1 ohne Lauf startet er den kompletten Lauf', Studio._letzterStart === 'full');
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'collect';
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C2 ein laufendes Sammeln wird auch als Sammeln abgebrochen',
|
||||
Studio._letzterStart === 'collect', Studio._letzterStart);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'analyze';
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C3 ein nicht abbrechbarer Schritt löst nichts aus',
|
||||
Studio._letzterStart === null, Studio._letzterStart);
|
||||
pruefe('C4 und sagt das auch', meldungen.length === 1 && meldungen[0].a === 'info', meldungen);
|
||||
|
||||
console.log('\nD) Die Kette');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
const kette = g('lauf-kette').innerHTML;
|
||||
pruefe('D1 vier Knoten', (kette.match(/class="kn /g) || []).length === 4,
|
||||
(kette.match(/class="kn /g) || []).length);
|
||||
pruefe('D2 drei Verbindungen', (kette.match(/kn-linie/g) || []).length === 3);
|
||||
pruefe('D3 alle vier erledigt', (kette.match(/kn-fertig/g) || []).length === 4);
|
||||
pruefe('D4 der Zähler stimmt',
|
||||
g('lauf-fortschritt').textContent === '4 von 4 Schritten auf dem neuesten Stand',
|
||||
g('lauf-fortschritt').textContent);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._renderLauf();
|
||||
pruefe('D5 bei veralteten Ergebnissen sind nicht alle grün',
|
||||
(g('lauf-kette').innerHTML.match(/kn-fertig/g) || []).length === 2,
|
||||
(g('lauf-kette').innerHTML.match(/kn-fertig/g) || []).length);
|
||||
pruefe('D6 zwei stehen auf veraltet',
|
||||
(g('lauf-kette').innerHTML.match(/kn-veraltet/g) || []).length === 2);
|
||||
pruefe('D7 der Zähler zählt nur die fertigen',
|
||||
g('lauf-fortschritt').textContent.startsWith('2 von 4'),
|
||||
g('lauf-fortschritt').textContent);
|
||||
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('D8 ohne Artikel sind drei Schritte gesperrt',
|
||||
(g('lauf-kette').innerHTML.match(/kn-gesperrt/g) || []).length === 3,
|
||||
(g('lauf-kette').innerHTML.match(/kn-gesperrt/g) || []).length);
|
||||
|
||||
console.log('\nE) Die Schritte');
|
||||
neu('halb');
|
||||
Studio._renderLauf();
|
||||
const l = g('lauf-schritte').innerHTML;
|
||||
pruefe('E1 vier Schritte', (l.match(/class="schritt /g) || []).length === 4,
|
||||
(l.match(/class="schritt /g) || []).length);
|
||||
pruefe('E2 Ergebnisnamen statt Erzeugernamen',
|
||||
l.includes('Artikel holen') && l.includes('Lagebild schreiben')
|
||||
&& l.includes('Fakten prüfen') && l.includes('Orte erkennen'));
|
||||
pruefe('E3 keine Erzeugernamen mehr',
|
||||
!l.includes('Geoparsing') && !l.includes('>Sammeln<') && !l.includes('>Analyse<'));
|
||||
pruefe('E4 der erste Schritt ist erledigt', l.includes('schritt schritt-fertig'));
|
||||
pruefe('E5 die noch nicht erzeugten sind offen',
|
||||
(l.match(/schritt-offen/g) || []).length === 2, (l.match(/schritt-offen/g) || []).length);
|
||||
pruefe('E6 die Karte meldet offene Artikel',
|
||||
l.includes('85 Artikel noch nicht verortet'));
|
||||
|
||||
neu('aktuell', { type: 'research' });
|
||||
Studio._renderLauf();
|
||||
pruefe('E7 bei einer Recherche heißt Schritt zwei Recherchebericht',
|
||||
g('lauf-schritte').innerHTML.includes('Recherchebericht schreiben'));
|
||||
|
||||
console.log('\nF) Kein Schritt startet etwas');
|
||||
for (const z of ['leer', 'veraltet', 'aktuell', 'halb']) {
|
||||
neu(z);
|
||||
Studio._renderLauf();
|
||||
const h = g('lauf-schritte').innerHTML;
|
||||
pruefe('F ' + z + ' enthält keinen Startaufruf',
|
||||
!h.includes('runStage') && !h.includes('laufKnopf'));
|
||||
}
|
||||
|
||||
console.log('\nG) Aufklappen erklärt nur');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('G1 anfangs ist nichts offen',
|
||||
!g('lauf-schritte').innerHTML.includes('schritt offen')
|
||||
&& !g('lauf-schritte').innerHTML.includes(' offen"'));
|
||||
Studio.klappSchritt(1);
|
||||
pruefe('G2 ein Klick öffnet den Schritt',
|
||||
g('lauf-schritte').innerHTML.includes(' offen"'), Studio._schrittOffen);
|
||||
pruefe('G3 und zeigt die Erklärung',
|
||||
g('lauf-schritte').innerHTML.includes('Wertet alle Artikel aus'));
|
||||
Studio.klappSchritt(1);
|
||||
pruefe('G4 nochmal klicken schließt ihn', Studio._schrittOffen === null);
|
||||
Studio.klappSchritt(0);
|
||||
Studio.klappSchritt(2);
|
||||
pruefe('G5 es ist immer nur einer offen', Studio._schrittOffen === 2);
|
||||
|
||||
console.log('\nH) Nebenangaben an den Ansichten');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('H1 Lagebild nennt die Artikelzahl',
|
||||
g('art-summary-meta').textContent.includes('85 Artikel ausgewertet'),
|
||||
g('art-summary-meta').textContent);
|
||||
pruefe('H2 Faktencheck nennt die Fakten',
|
||||
g('art-fc-meta').textContent.includes('19 Fakten'), g('art-fc-meta').textContent);
|
||||
pruefe('H3 frühere Berichte werden gezählt', g('art-snap-meta').textContent === '4');
|
||||
pruefe('H4 Ereignisse werden gezählt',
|
||||
g('art-tl-meta').textContent === '12 Ereignisse', g('art-tl-meta').textContent);
|
||||
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('H5 ohne Lauf steht noch nicht erzeugt',
|
||||
g('art-summary-meta').textContent === 'noch nicht erzeugt',
|
||||
g('art-summary-meta').textContent);
|
||||
|
||||
console.log('\nI) Ohne offenen Fall');
|
||||
neu('leer');
|
||||
Studio.incident = null;
|
||||
Studio.fresh = null;
|
||||
Studio._renderLauf();
|
||||
pruefe('I1 der Knopf ist gesperrt', g('ls-btn').disabled === true);
|
||||
pruefe('I2 der Stand sagt es', g('ls-haupt').textContent === 'Kein Fall geöffnet',
|
||||
g('ls-haupt').textContent);
|
||||
pruefe('I3 die Kette ist leer', g('lauf-kette').innerHTML === '');
|
||||
pruefe('I4 die Schrittliste ist leer', g('lauf-schritte').innerHTML === '');
|
||||
pruefe('I5 nichts stürzt ab', true);
|
||||
|
||||
console.log('\nJ) Ohne Frischedaten');
|
||||
neu('leer');
|
||||
Studio.fresh = null;
|
||||
Studio._renderLauf();
|
||||
pruefe('J1 die Anzeige bleibt bedienbar', g('ls-btn').disabled === false);
|
||||
pruefe('J2 und behandelt den Fall wie leer',
|
||||
g('ls-haupt').textContent.includes('Noch keine Artikel'));
|
||||
|
||||
console.log('\nK) Der Leerzustand räumt die Anzeige auf');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('K1 zunächst steht der Stand des Falls da',
|
||||
g('ls-haupt').textContent.includes('neuesten Stand'));
|
||||
Studio._showEmpty(true);
|
||||
pruefe('K2 danach steht dort kein alter Stand mehr',
|
||||
g('ls-haupt').textContent === 'Kein Fall geöffnet', g('ls-haupt').textContent);
|
||||
pruefe('K3 die Kette ist geleert', g('lauf-kette').innerHTML === '');
|
||||
pruefe('K4 die Schrittliste ist geleert', g('lauf-schritte').innerHTML === '');
|
||||
pruefe('K5 der Knopf ist gesperrt', g('ls-btn').disabled === true);
|
||||
pruefe('K6 der Fall ist wirklich losgelassen', Studio.incident === null);
|
||||
|
||||
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
107
tests/test_mehrfachantwort.py
Normale Datei
107
tests/test_mehrfachantwort.py
Normale Datei
@@ -0,0 +1,107 @@
|
||||
"""Testet den Umgang mit Antworten, die mehrere Fassungen enthalten.
|
||||
|
||||
Modelle korrigieren sich gelegentlich selbst und haengen nach einer ersten,
|
||||
knappen Antwort eine zweite, vollstaendige an. Wer nur die erste nimmt,
|
||||
verliert den Grossteil des Berichts. Genau das ist in Lage 49 und 50 passiert,
|
||||
dort blieb von sechs Abschnitten nur einer uebrig.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_mehrfachantwort.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
from json_utils import ( # noqa: E402
|
||||
extract_json_object, extract_json_objects, extract_json_array, extract_json_arrays,
|
||||
)
|
||||
from agents.analyzer import AnalyzerAgent # noqa: E402
|
||||
from agents.factchecker import FactCheckerAgent # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
KURZ = "## ZUSAMMENFASSUNG\\n\\n- Ein einzelner Punkt."
|
||||
LANG = ("## ZUSAMMENFASSUNG\\n\\n- Punkt eins.\\n\\n"
|
||||
"## HINTERGRUND\\n\\nDie Vorgeschichte.\\n\\n"
|
||||
"## AKTEURE\\n\\nDie Beteiligten.\\n\\n"
|
||||
"## AKTUELLE LAGE\\n\\nDer Stand.\\n\\n"
|
||||
"## EINSCHÄTZUNG\\n\\nDie Bewertung.\\n\\n"
|
||||
"## QUELLENQUALITÄT\\n\\nDie Belege.")
|
||||
|
||||
SELBSTKORREKTUR = (
|
||||
'```json\n{\n "summary": "' + KURZ + '",\n "sources": [{"nr": 1}]\n}\n```\n\n'
|
||||
'Wait, I need to include the full briefing content in the summary field. '
|
||||
'Let me redo this properly with all sections.\n\n'
|
||||
'```json\n{\n "summary": "' + LANG + '",\n "sources": [{"nr": 1}, {"nr": 2}]\n}\n```'
|
||||
)
|
||||
|
||||
print("\nA) Alle Fassungen werden gefunden")
|
||||
objekte = extract_json_objects(SELBSTKORREKTUR)
|
||||
pruefe("A1 beide Fassungen erkannt", len(objekte) == 2, len(objekte))
|
||||
pruefe("A2 erste Fassung ist die knappe",
|
||||
"HINTERGRUND" not in objekte[0]["summary"])
|
||||
pruefe("A3 zweite Fassung ist die vollstaendige",
|
||||
"QUELLENQUALITÄT" in objekte[1]["summary"])
|
||||
pruefe("A4 Einzelabfrage liefert weiterhin die erste",
|
||||
"HINTERGRUND" not in extract_json_object(SELBSTKORREKTUR)["summary"])
|
||||
|
||||
print("\nB) Der Analyzer waehlt die vollstaendige Fassung")
|
||||
bericht = AnalyzerAgent()._parse_response(SELBSTKORREKTUR)
|
||||
pruefe("B1 Objekt erhalten", isinstance(bericht, dict))
|
||||
abschnitte = re.findall(r"## *([A-ZÄÖÜ ]{4,20})", bericht.get("summary", ""))
|
||||
pruefe("B2 alle sechs Abschnitte enthalten", len(abschnitte) == 6, abschnitte)
|
||||
pruefe("B3 Quellen der gewaehlten Fassung", len(bericht.get("sources", [])) == 2,
|
||||
bericht.get("sources"))
|
||||
|
||||
print("\nC) Eine einzelne saubere Antwort bleibt unveraendert")
|
||||
einfach = '{"summary": "' + LANG + '", "sources": [{"nr": 1}]}'
|
||||
b2 = AnalyzerAgent()._parse_response(einfach)
|
||||
pruefe("C1 unveraendert geparst", isinstance(b2, dict) and "QUELLENQUALITÄT" in b2["summary"])
|
||||
pruefe("C2 nur eine Fassung gefunden", len(extract_json_objects(einfach)) == 1)
|
||||
|
||||
print("\nD) Dasselbe fuer Faktenlisten")
|
||||
FAKTEN_KURZ = '[{"claim": "Nur ein Fakt", "status": "confirmed", "evidence": "e"}]'
|
||||
FAKTEN_LANG = ('[{"claim": "Erster Fakt", "status": "confirmed", "evidence": "e"},'
|
||||
' {"claim": "Zweiter Fakt", "status": "confirmed", "evidence": "e"},'
|
||||
' {"claim": "Dritter Fakt", "status": "confirmed", "evidence": "e"}]')
|
||||
FC_KORREKTUR = (FAKTEN_KURZ + "\n\nMoment, ich habe Fakten vergessen. Hier vollstaendig:\n\n"
|
||||
+ FAKTEN_LANG)
|
||||
listen = extract_json_arrays(FC_KORREKTUR)
|
||||
pruefe("D1 beide Listen erkannt", len(listen) == 2, len(listen))
|
||||
pruefe("D2 Einzelabfrage liefert weiterhin die erste",
|
||||
len(extract_json_array(FC_KORREKTUR)) == 1)
|
||||
fakten = FactCheckerAgent()._parse_response(FC_KORREKTUR)
|
||||
pruefe("D3 Faktencheck nimmt die vollstaendige Liste", len(fakten) == 3, len(fakten))
|
||||
|
||||
print("\nE) Unbrauchbare Antworten bleiben unbrauchbar")
|
||||
pruefe("E1 Fliesstext liefert keine Objekte", extract_json_objects("Nur Text") == [])
|
||||
pruefe("E2 Fliesstext liefert keine Listen", extract_json_arrays("Nur Text") == [])
|
||||
pruefe("E3 leerer Text ist unkritisch",
|
||||
extract_json_objects("") == [] and extract_json_arrays("") == [])
|
||||
|
||||
print("\nF) Kaputte erste Fassung, brauchbare zweite")
|
||||
KAPUTT = ('{"summary": "abgeschnitten ' + "\n\n"
|
||||
+ '{"summary": "' + LANG + '", "sources": []}')
|
||||
objekte_f = extract_json_objects(KAPUTT)
|
||||
pruefe("F1 die brauchbare Fassung wird gefunden",
|
||||
any("QUELLENQUALITÄT" in str(o.get("summary", "")) for o in objekte_f),
|
||||
len(objekte_f))
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
277
tests/test_qc_und_runden.py
Normale Datei
277
tests/test_qc_und_runden.py
Normale Datei
@@ -0,0 +1,277 @@
|
||||
"""Testet die Absicherung der Duplikatpruefung und den Abbruch der Suchrunden.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten, Datenbank, Modell und Suche sind
|
||||
durch Testdoubles ersetzt.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_qc_und_runden.py
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import services.post_refresh_qc as qc # noqa: E402
|
||||
import agents.eu_researcher as eur # noqa: E402
|
||||
from agents.claude_client import ClaudeUsage # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Testdouble fuer die Datenbank
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
async def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
async def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class FakeDB:
|
||||
"""Liefert eine feste Faktenliste und merkt sich die Loeschauftraege."""
|
||||
|
||||
def __init__(self, fakten):
|
||||
self.fakten = fakten
|
||||
self.geloescht = []
|
||||
|
||||
async def execute(self, sql, params=()):
|
||||
if sql.strip().upper().startswith("DELETE"):
|
||||
self.geloescht.extend(params)
|
||||
return FakeCursor([])
|
||||
return FakeCursor(list(self.fakten))
|
||||
|
||||
|
||||
def fakt(fid, claim, status="established", quellen=3):
|
||||
return {"id": fid, "claim": claim, "status": status, "sources_count": quellen,
|
||||
"evidence": "e", "checked_at": "2026-08-01 10:00:00"}
|
||||
|
||||
|
||||
FAHRPLAN = "Die IMK beschloss einen gemeinsamen Bund-Laender-Fahrplan zum Aufbau der zivilen Verteidigungsfaehigkeit bis 2029"
|
||||
FAHRPLAN_ANDERS = "Die IMK beschloss einen Bund-Laender-Fahrplan fuer die zivile Verteidigung bis zum Jahr 2029"
|
||||
BUNDESWEHR = "Die IMK beschloss die institutionelle Einbindung der Bundeswehr in die Innenministerkonferenz"
|
||||
SOZIAL = "Die IMK beschloss Massnahmen zur haerteren Bekaempfung von organisiertem Sozialmissbrauch"
|
||||
MILLIARDEN = "Die IMK forderte zusaetzliche Milliardeninvestitionen des Bundes in den Zivilschutz"
|
||||
ABSCHIEBUNG = "Die Innenminister waren sich einig, dass der Bund bei Abschiebungen mehr leisten muss"
|
||||
GDP = "Die Gewerkschaft der Polizei bewertete die Beschluesse als grundsaetzlich richtig"
|
||||
SYRER = "In der Frage der Rueckfuehrung von Syrern besteht ein Konflikt zwischen SPD und CDU"
|
||||
IMK_DATUM = "Die 225. Innenministerkonferenz fand im Juni 2026 in Hamburg statt"
|
||||
BESCHLUESSE = "Die IMK fasste insgesamt 66 Beschluesse"
|
||||
|
||||
geplante_cluster = []
|
||||
|
||||
|
||||
async def fake_cluster(facts, incident_title):
|
||||
return geplante_cluster
|
||||
|
||||
|
||||
qc._haiku_find_duplicate_clusters = fake_cluster
|
||||
|
||||
|
||||
def lauf_qc(fakten, cluster):
|
||||
global geplante_cluster
|
||||
geplante_cluster = cluster
|
||||
db = FakeDB(fakten)
|
||||
entfernt = asyncio.run(qc.check_fact_duplicates(db, 49, "Innenministerkonferenz 2026"))
|
||||
return entfernt, db.geloescht
|
||||
|
||||
|
||||
print("\nA) Der Fall aus Lage 49, sieben verschiedene Fakten in einer Gruppe")
|
||||
zehn = [
|
||||
fakt(469, FAHRPLAN), fakt(468, IMK_DATUM), fakt(470, BUNDESWEHR),
|
||||
fakt(471, SOZIAL), fakt(472, MILLIARDEN), fakt(473, ABSCHIEBUNG),
|
||||
fakt(474, SYRER, "disputed"), fakt(475, BESCHLUESSE), fakt(476, GDP),
|
||||
fakt(477, "Die IMK befasste sich mit der Umsetzung des europaeischen Asylsystems"),
|
||||
]
|
||||
entfernt, geloescht = lauf_qc(zehn, [[468, 469, 470, 471, 472, 473, 475, 476]])
|
||||
pruefe("A1 unplausible Gruppe wird komplett verworfen", entfernt == 0, entfernt)
|
||||
pruefe("A2 kein Fakt geloescht", geloescht == [], geloescht)
|
||||
|
||||
print("\nB) Echte Dubletten werden weiterhin entfernt")
|
||||
entfernt, geloescht = lauf_qc(
|
||||
[fakt(1, FAHRPLAN, quellen=8), fakt(2, FAHRPLAN_ANDERS, quellen=2),
|
||||
fakt(3, BUNDESWEHR), fakt(4, SOZIAL), fakt(5, MILLIARDEN)],
|
||||
[[1, 2]])
|
||||
pruefe("B1 echtes Duplikat entfernt", entfernt == 1, entfernt)
|
||||
pruefe("B2 der besser belegte Fakt bleibt", geloescht == [2], geloescht)
|
||||
|
||||
print("\nC) Kleine Gruppe mit unaehnlichen Fakten wird nicht geloescht")
|
||||
entfernt, geloescht = lauf_qc(
|
||||
[fakt(1, FAHRPLAN), fakt(2, SOZIAL), fakt(3, BUNDESWEHR), fakt(4, MILLIARDEN),
|
||||
fakt(5, ABSCHIEBUNG), fakt(6, GDP)],
|
||||
[[1, 2]])
|
||||
pruefe("C1 unaehnlicher Fakt bleibt erhalten", entfernt == 0, entfernt)
|
||||
|
||||
print("\nD) Gemischte Gruppe, nur der wirklich aehnliche Fakt faellt weg")
|
||||
entfernt, geloescht = lauf_qc(
|
||||
[fakt(1, FAHRPLAN, quellen=9), fakt(2, FAHRPLAN_ANDERS, quellen=1), fakt(3, SOZIAL),
|
||||
fakt(4, BUNDESWEHR), fakt(5, MILLIARDEN), fakt(6, ABSCHIEBUNG),
|
||||
fakt(7, GDP), fakt(8, SYRER), fakt(9, BESCHLUESSE), fakt(10, IMK_DATUM)],
|
||||
[[1, 2, 3]])
|
||||
pruefe("D1 genau ein Fakt entfernt", entfernt == 1, entfernt)
|
||||
pruefe("D2 der aehnliche wurde entfernt", geloescht == [2], geloescht)
|
||||
|
||||
print("\nE) Grenzwerte")
|
||||
pruefe("E1 Anteilsgrenze gesetzt", 0 < qc.DEDUP_MAX_CLUSTER_ANTEIL <= 1, qc.DEDUP_MAX_CLUSTER_ANTEIL)
|
||||
pruefe("E2 Aehnlichkeitsgrenze gesetzt", 0 < qc.DEDUP_MIN_AEHNLICHKEIT < 1, qc.DEDUP_MIN_AEHNLICHKEIT)
|
||||
pruefe("E3 verschiedene Sachverhalte liegen unter der Grenze",
|
||||
qc._aehnlichkeit(FAHRPLAN, SOZIAL) < qc.DEDUP_MIN_AEHNLICHKEIT,
|
||||
round(qc._aehnlichkeit(FAHRPLAN, SOZIAL), 2))
|
||||
pruefe("E4 echte Dublette liegt ueber der Grenze",
|
||||
qc._aehnlichkeit(FAHRPLAN, FAHRPLAN_ANDERS) >= qc.DEDUP_MIN_AEHNLICHKEIT,
|
||||
round(qc._aehnlichkeit(FAHRPLAN, FAHRPLAN_ANDERS), 2))
|
||||
pruefe("E5 bei zwei Fakten bleibt die Gruppengrenze nutzbar",
|
||||
max(2, int(2 * qc.DEDUP_MAX_CLUSTER_ANTEIL)) == 2)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suchrunden
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nF) Die Suchphase endet, sobald die Treffergrenze erreicht ist")
|
||||
|
||||
runden = {"n": 0}
|
||||
|
||||
|
||||
async def fake_bedrock_research(prompt, model=None, raw_text=False, timeout=None):
|
||||
runden["n"] += 1
|
||||
# Jede Runde fordert vier Suchen an, das Modell will nie von selbst enden.
|
||||
antwort = {"action": "search", "reason": "weiter",
|
||||
"queries": [{"q": f"anfrage {runden['n']}-{i}", "market": "de-de",
|
||||
"full_content": False} for i in range(1, 5)]}
|
||||
return json.dumps(antwort), ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
|
||||
|
||||
|
||||
async def fake_staan_research(q, market="de-de", extra_snippets=True, max_snippets=4,
|
||||
full_content=False, extra_exclude=None, timeout=None):
|
||||
# Jede Suche liefert 20 neue Treffer, nach zwei Runden ist die Grenze voll.
|
||||
basis = q.replace(" ", "")
|
||||
return [{
|
||||
"title": "Treffer " + basis + str(i),
|
||||
"hostname": "example.org",
|
||||
"url": "https://example.org/" + basis + "/" + str(i),
|
||||
"snippet": "Auszug",
|
||||
"extra_snippets": [],
|
||||
"full_text": "",
|
||||
} for i in range(1, 21)]
|
||||
|
||||
|
||||
letzter_prompt = {"text": ""}
|
||||
|
||||
|
||||
async def fake_bedrock_final(prompt, model=None, raw_text=False, timeout=None):
|
||||
"""Ab dem Abschlussauftrag wird eine Auswahl zurueckgegeben."""
|
||||
letzter_prompt["text"] = prompt
|
||||
return "[]", ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
|
||||
|
||||
|
||||
async def bedrock_weiche(prompt, model=None, raw_text=False, timeout=None):
|
||||
# Der Rundenauftrag traegt die Rundennummer im Kopf, der Abschlussauftrag
|
||||
# nicht. Daran laesst sich beides sicher unterscheiden.
|
||||
if "Es ist Runde " in prompt:
|
||||
return await fake_bedrock_research(prompt, model, raw_text, timeout)
|
||||
return await fake_bedrock_final(prompt, model, raw_text, timeout)
|
||||
|
||||
|
||||
eur.call_bedrock = bedrock_weiche
|
||||
eur.staan_search = fake_staan_research
|
||||
|
||||
runden["n"] = 0
|
||||
text, usage = asyncio.run(eur.run_eu_research(
|
||||
title="Innenministerkonferenz 2026", description="", incident_type="research",
|
||||
lang_instruction="", existing_context="", preferred_sources_block="",
|
||||
output_language="Deutsch", excluded_sources=[], research_language_iso="de",
|
||||
))
|
||||
# Jede Suche liefert 20 Treffer, das Kontingent je Runde liegt bei 25. Die
|
||||
# Runde nimmt also zwei Suchen auf und ueberspringt den Rest. Entscheidend:
|
||||
# die Schleife laeuft weiter, damit die spaeteren Runden ihre Aufgabe
|
||||
# (Luecken schliessen, vertiefen) ueberhaupt ausfuehren koennen. Vorher war
|
||||
# nach Runde 2 Schluss, weil die Trefferzahl als harte Grenze wirkte.
|
||||
suchrunden = runden["n"]
|
||||
pruefe("F1 alle Runden laufen, die Vertiefung faellt nicht mehr aus",
|
||||
suchrunden == eur.EU_RESEARCH_MAX_ROUNDS_RESEARCH, suchrunden)
|
||||
pruefe("F2 je Runde greift das Kontingent statt eines Gesamtstopps",
|
||||
eur.EU_RESEARCH_MAX_NEW_PER_ROUND == 25, eur.EU_RESEARCH_MAX_NEW_PER_ROUND)
|
||||
pruefe("F3 Recherche liefert ein Ergebnis", text == "[]", text[:40])
|
||||
pruefe("F4 Kosten entsprechen den gelaufenen Runden",
|
||||
usage.cost_usd > 0.05 * suchrunden, round(usage.cost_usd, 4))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G) Sättigung beendet die Suchphase weiterhin
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nG) Keine neuen Treffer, keine weiteren Runden")
|
||||
|
||||
|
||||
async def fake_staan_immer_gleich(q, market="de-de", extra_snippets=True, max_snippets=4,
|
||||
full_content=False, extra_exclude=None, timeout=None):
|
||||
"""Liefert immer dieselben fuenf Treffer: ab Runde 2 gibt es nichts Neues."""
|
||||
return [{
|
||||
"title": "Immer derselbe " + str(i), "hostname": "example.org",
|
||||
"url": "https://example.org/immer/" + str(i),
|
||||
"snippet": "Auszug", "extra_snippets": [], "full_text": "",
|
||||
} for i in range(1, 6)]
|
||||
|
||||
|
||||
eur.staan_search = fake_staan_immer_gleich
|
||||
runden["n"] = 0
|
||||
asyncio.run(eur.run_eu_research(
|
||||
title="Innenministerkonferenz 2026", description="", incident_type="research",
|
||||
lang_instruction="", existing_context="", preferred_sources_block="",
|
||||
output_language="Deutsch", excluded_sources=[], research_language_iso="de",
|
||||
))
|
||||
pruefe("G1 Suchphase endet nach der ersten Runde ohne neue Treffer",
|
||||
runden["n"] == 2, runden["n"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# H) Verdrängung schafft Platz für spätere Runden
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nH) Platz fuer gezielte Treffer")
|
||||
korb = {
|
||||
"https://a.de/1": {"_runde": 1, "snippet": "", "full_text": ""},
|
||||
"https://b.de/1": {"_runde": 1, "snippet": "Auszug", "full_text": ""},
|
||||
"https://c.de/1": {"_runde": 2, "snippet": "", "full_text": ""},
|
||||
}
|
||||
pruefe("H1 der schwaechste Treffer einer frueheren Runde weicht",
|
||||
eur._platz_schaffen(korb, 3) and "https://a.de/1" not in korb, list(korb))
|
||||
nur_volltext = {"https://a.de/1": {"_runde": 1, "snippet": "x", "full_text": "langer Text"}}
|
||||
pruefe("H2 Treffer mit Volltext werden nicht verdraengt",
|
||||
not eur._platz_schaffen(nur_volltext, 3) and len(nur_volltext) == 1)
|
||||
gleiche_runde = {"https://a.de/1": {"_runde": 3, "snippet": "", "full_text": ""}}
|
||||
pruefe("H3 Treffer derselben Runde werden nicht verdraengt",
|
||||
not eur._platz_schaffen(gleiche_runde, 3) and len(gleiche_runde) == 1)
|
||||
pruefe("H4 leerer Korb meldet keinen Erfolg", not eur._platz_schaffen({}, 2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I) Die dritte Adhoc-Runde hat einen eigenen Auftrag
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nI) Auftrag der Vertiefungsrunde")
|
||||
dritte = eur._PHASE_GUIDANCE_ADHOC.get(3, "")
|
||||
pruefe("I1 dritte Runde ist als Vertiefung beschrieben", "VERTIEFUNG" in dritte, dritte[:60])
|
||||
for stichwort, name in (
|
||||
("fehlt", "I2 fragt nach der fehlenden Seite der Lage"),
|
||||
("Institutionen", "I3 nennt Institutionen als typische Luecke"),
|
||||
("rechtliche", "I4 nennt den rechtlichen Rahmen"),
|
||||
("nicht noch einmal", "I5 verbietet die Wiederholung des Hauptereignisses"),
|
||||
):
|
||||
pruefe(name, stichwort in dritte)
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
302
tests/test_quellenausgabe.py
Normale Datei
302
tests/test_quellenausgabe.py
Normale Datei
@@ -0,0 +1,302 @@
|
||||
"""Testet die Ausgabetreue des Quellenverzeichnisses.
|
||||
|
||||
Hintergrund ist der Vergleich zweier Berichte zur selben Lage am 01.08.2026
|
||||
(Ceuta). Beide Fassungen verwiesen im Text auf Quellennummern, die im
|
||||
gedruckten Verzeichnis nicht auftauchten. Ursache war nicht die Analyse, in der
|
||||
Datenbank passten Text und Liste zusammen, sondern die Ausgabe: Das Template
|
||||
druckte die laufende Zeilennummer statt der Quellennummer aus dem Lagebild.
|
||||
Sobald die Nummern Luecken haben, verschiebt sich damit jeder Beleg.
|
||||
|
||||
Laeuft ohne Netzzugriff, ohne Kosten und ohne Datenbank.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_quellenausgabe.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader # noqa: E402
|
||||
|
||||
import report_generator as rg # noqa: E402
|
||||
from services import media_registry as mr # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Domain-Erkennung
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nA. Registrierbare Domain")
|
||||
pruefe("A1 www wird abgeschnitten",
|
||||
mr.registrable_domain("https://www.theguardian.com/world/x") == "theguardian.com",
|
||||
mr.registrable_domain("https://www.theguardian.com/world/x"))
|
||||
pruefe("A2 mehrteilige Endung bleibt erhalten",
|
||||
mr.registrable_domain("https://www.bbc.co.uk/news/articles/y") == "bbc.co.uk",
|
||||
mr.registrable_domain("https://www.bbc.co.uk/news/articles/y"))
|
||||
pruefe("A3 Sprachsubdomain zaehlt zur selben Domain",
|
||||
mr.registrable_domain("https://english.elpais.com/spain/a.html") == "elpais.com",
|
||||
mr.registrable_domain("https://english.elpais.com/spain/a.html"))
|
||||
pruefe("A4 leere Eingabe bleibt leer", mr.registrable_domain("") == "")
|
||||
pruefe("A5 Weiterleitungsportal wird erkannt",
|
||||
mr.ist_aggregator("https://news.google.com/rss/articles/CBMi123?oc=5"))
|
||||
pruefe("A6 echtes Medium ist kein Aggregator",
|
||||
not mr.ist_aggregator("https://www.tagesschau.de/ausland/europa/x-100.html"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Ein Verlag, ein Name
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nB. Namensvereinheitlichung")
|
||||
eintraege = [
|
||||
("https://www.theguardian.com/world/a", "Guardian World"),
|
||||
("https://www.theguardian.com/world/b", "Guardian UK"),
|
||||
("https://www.theguardian.com/live/c", "The Guardian"),
|
||||
("https://www.theguardian.com/live/d", "The Guardian"),
|
||||
("https://www.france24.com/en/x", "www.france24.com"),
|
||||
]
|
||||
namen = mr.namen_je_domain(eintraege)
|
||||
pruefe("B1 drei Guardian-Varianten ergeben einen Namen",
|
||||
namen.get("theguardian.com") == "The Guardian", namen.get("theguardian.com"))
|
||||
pruefe("B2 Hostname als Name faellt auf die Tabelle zurueck",
|
||||
namen.get("france24.com") == "France24", namen.get("france24.com"))
|
||||
pruefe("B3 unbekannte Domain bekommt einen lesbaren Namen",
|
||||
mr.name_aus_domain("beispielzeitung.de") == "Beispielzeitung",
|
||||
mr.name_aus_domain("beispielzeitung.de"))
|
||||
# Der Feed-Name darf den Mediennamen nicht verdraengen: im Bericht vom
|
||||
# 01.08.2026 stand "Guardian World", "NYT Top Stories" und "BBC Europe".
|
||||
feednamen = mr.namen_je_domain([
|
||||
("https://www.theguardian.com/world/a", "Guardian World"),
|
||||
("https://www.theguardian.com/world/b", "Guardian World"),
|
||||
("https://www.nytimes.com/2026/08/01/x.html", "NYT Top Stories"),
|
||||
("https://www.bbc.co.uk/news/y", "BBC Europe"),
|
||||
])
|
||||
pruefe("B4 Medienname schlaegt Feed-Name (Guardian)",
|
||||
feednamen.get("theguardian.com") == "The Guardian", feednamen.get("theguardian.com"))
|
||||
pruefe("B5 Medienname schlaegt Feed-Name (NYT)",
|
||||
feednamen.get("nytimes.com") == "New York Times", feednamen.get("nytimes.com"))
|
||||
pruefe("B6 Medienname schlaegt Feed-Name (BBC)",
|
||||
feednamen.get("bbc.co.uk") == "BBC", feednamen.get("bbc.co.uk"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. Quellenverzeichnis behaelt die Nummern aus dem Lagebild
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC. Quellenaufbereitung")
|
||||
# Nachgebaut nach Lage 53 (EU-Fassung): 25 Eintraege, aber Nummern bis 28.
|
||||
quellen_roh = [{"nr": n, "name": f"Medium {n}", "url": f"https://medium{n}.de/artikel"}
|
||||
for n in [1, 2, 3, 20, 22, 24, 26, 27, 28]]
|
||||
incident = {"sources_json": json.dumps(quellen_roh, ensure_ascii=False)}
|
||||
sources = rg._prepare_sources(incident)
|
||||
nummern = [s["nr"] for s in sources]
|
||||
pruefe("C1 Luecken in der Nummerierung bleiben erhalten",
|
||||
nummern == [1, 2, 3, 20, 22, 24, 26, 27, 28], nummern)
|
||||
pruefe("C2 hoechste Nummer ueberlebt die Aufbereitung", max(nummern) == 28, max(nummern))
|
||||
|
||||
# Buchstaben-Suffixe und kaputte Nummern duerfen nichts umwerfen
|
||||
gemischt = {"sources_json": json.dumps([
|
||||
{"nr": "7a", "name": "A", "url": "https://a.de/1"},
|
||||
{"nr": None, "name": "B", "url": "https://b.de/1"},
|
||||
{"nr": 3, "name": "C", "url": "https://c.de/1"},
|
||||
], ensure_ascii=False)}
|
||||
gemischte_nrn = [s["nr"] for s in rg._prepare_sources(gemischt)]
|
||||
pruefe("C3 Suffix wird zur Zahl, fehlende Nummer bekommt die Position",
|
||||
all(isinstance(n, int) for n in gemischte_nrn) and 7 in gemischte_nrn,
|
||||
gemischte_nrn)
|
||||
|
||||
# Namen im Verzeichnis vereinheitlicht
|
||||
mehrfach = {"sources_json": json.dumps([
|
||||
{"nr": 1, "name": "Guardian World", "url": "https://www.theguardian.com/a"},
|
||||
{"nr": 2, "name": "The Guardian", "url": "https://www.theguardian.com/b"},
|
||||
{"nr": 3, "name": "The Guardian", "url": "https://www.theguardian.com/c"},
|
||||
], ensure_ascii=False)}
|
||||
namen_liste = {s["name"] for s in rg._prepare_sources(mehrfach)}
|
||||
pruefe("C4 Verzeichnis fuehrt den Verlag unter einem Namen",
|
||||
namen_liste == {"The Guardian"}, namen_liste)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C2. Lueckenlose Nummerierung fuer die Ausgabe
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC2. Umnummerierung im Bericht")
|
||||
neu, abbildung = rg._renumber_sources(sources)
|
||||
pruefe("C5 Ausgabe zaehlt lueckenlos ab 1",
|
||||
[s["nr"] for s in neu] == list(range(1, len(sources) + 1)), [s["nr"] for s in neu])
|
||||
pruefe("C6 Abbildung fuehrt die hoechste alte Nummer auf die letzte Zeile",
|
||||
abbildung.get(28) == len(sources), abbildung.get(28))
|
||||
text_alt = "Erstens [1], zweitens [22] und drittens [28]."
|
||||
text_neu = rg._apply_citation_map(text_alt, abbildung)
|
||||
pruefe("C7 Verweise im Text folgen der neuen Zaehlung",
|
||||
text_neu == f"Erstens [1], zweitens [{abbildung[22]}] und drittens [{abbildung[28]}].", text_neu)
|
||||
verwaist = rg._apply_citation_map("Beleg [21] existiert nicht, [1] schon.", abbildung)
|
||||
pruefe("C8 Verweis ohne Verzeichniseintrag wird entfernt, nicht verschoben",
|
||||
"[21]" not in verwaist and "[1]" in verwaist and "[2]" not in verwaist, verwaist)
|
||||
pruefe("C9 leere Abbildung laesst den Text unveraendert",
|
||||
rg._apply_citation_map(text_alt, {}) == text_alt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C3. Doppelte Adressen
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC3. Doppelte Adressen im Verzeichnis")
|
||||
# Nachgebaut nach Lage 56: 30 Eintraege, aber nur 28 verschiedene Adressen.
|
||||
# Zweimal dieselbe tagesschau-Meldung stuetzte dort die strittigste Behauptung.
|
||||
doppelt = rg._prepare_sources({"sources_json": json.dumps([
|
||||
{"nr": 22, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/a-100.html"},
|
||||
{"nr": 23, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/a-100.html"},
|
||||
{"nr": 24, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/b-104.html"},
|
||||
{"nr": 25, "name": "tagesschau", "url": "http://tagesschau.de/ausland/b-104.html/"},
|
||||
{"nr": 26, "name": "ZDF heute", "url": "https://www.zdfheute.de/c.html"},
|
||||
], ensure_ascii=False)})
|
||||
pruefe("C10 gleiche Adresse ergibt einen Eintrag", len(doppelt) == 3, len(doppelt))
|
||||
pruefe("C11 die kleinere Nummer bleibt bestehen",
|
||||
[s["nr"] for s in doppelt] == [22, 24, 26], [s["nr"] for s in doppelt])
|
||||
pruefe("C12 Schema, www und Endschraegstrich zaehlen nicht als Unterschied",
|
||||
rg._url_schluessel("https://www.x.de/a/") == rg._url_schluessel("http://x.de/a"))
|
||||
|
||||
neu_d, abb_d = rg._renumber_sources(doppelt)
|
||||
pruefe("C13 Ausgabe zaehlt wieder lueckenlos", [s["nr"] for s in neu_d] == [1, 2, 3],
|
||||
[s["nr"] for s in neu_d])
|
||||
pruefe("C14 Verweis auf die entfernte Nummer zeigt auf den behaltenen Eintrag",
|
||||
abb_d.get(23) == abb_d.get(22) == 1 and abb_d.get(25) == abb_d.get(24) == 2, abb_d)
|
||||
# Zeigen zwei Verweise nach dem Zusammenfuehren auf dieselbe Zahl, darf im
|
||||
# Text nicht "[24][24]" stehen bleiben. Im Bericht vom 02.08.2026 dreimal.
|
||||
pruefe("C15a doppelter Verweis auf dieselbe Nummer wird zusammengezogen",
|
||||
rg._apply_citation_map("Beleg [22][23] und Ende.", abb_d) == "Beleg [1] und Ende.",
|
||||
rg._apply_citation_map("Beleg [22][23] und Ende.", abb_d))
|
||||
pruefe("C15b verschiedene Nummern bleiben nebeneinander stehen",
|
||||
rg._apply_citation_map("Beleg [22][24].", abb_d) == "Beleg [1][2].",
|
||||
rg._apply_citation_map("Beleg [22][24].", abb_d))
|
||||
|
||||
# Eintraege ohne Adresse sind keine pruefbaren Belege.
|
||||
ohne_adresse = rg._prepare_sources({"sources_json": json.dumps([
|
||||
{"nr": 11, "name": "tagesschau", "url": "https://www.tagesschau.de/a.html"},
|
||||
{"nr": 12, "name": "Quelle", "url": ""},
|
||||
{"nr": 13, "name": "ZDF heute", "url": "https://www.zdfheute.de/b.html"},
|
||||
], ensure_ascii=False)})
|
||||
pruefe("C15c Eintrag ohne Adresse kommt nicht ins Verzeichnis",
|
||||
[s["nr"] for s in ohne_adresse] == [11, 13], [s["nr"] for s in ohne_adresse])
|
||||
_, abb_o = rg._renumber_sources(ohne_adresse)
|
||||
pruefe("C15d Verweis auf den entfernten Eintrag wird getilgt",
|
||||
rg._apply_citation_map("Beleg [11][12].", abb_o) == "Beleg [1].",
|
||||
rg._apply_citation_map("Beleg [11][12].", abb_o))
|
||||
|
||||
pruefe("C15 Text mit beiden Nummern bleibt aufloesbar",
|
||||
rg._apply_citation_map("Beleg [22] und [23].", abb_d) == "Beleg [1] und [1].",
|
||||
rg._apply_citation_map("Beleg [22] und [23].", abb_d))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C4. Zeitliche Sortierung der Neuesten Entwicklungen
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC4. Neueste Entwicklungen")
|
||||
# Nachgebaut nach Lage 56: der letzte Eintrag sprang von 12:44 zurueck auf 17:47.
|
||||
roh = "\n".join([
|
||||
"- [01.08. 23:23] Barriere installiert. {A|https://a.de/1}",
|
||||
"- [01.08. 12:44] Italien setzt Schengen aus. {B|https://b.de/1}",
|
||||
"- [01.08. 17:47] Todeszahl steigt. {C|https://c.de/1}",
|
||||
"- [02.08. 00:05] Neuer Tag. {D|https://d.de/1}",
|
||||
])
|
||||
paare = rg._parse_developments_for_export(roh)
|
||||
pruefe("C16 Eintraege stehen absteigend nach Zeit",
|
||||
[p[0][:14] for p in paare] == ["02.08.26, 00:0", "01.08.26, 23:2", "01.08.26, 17:4", "01.08.26, 12:4"],
|
||||
[p[0] for p in paare])
|
||||
pruefe("C17 kein Eintrag geht verloren", len(paare) == 4, len(paare))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D. Statistik und Verzeichnis widersprechen sich nicht mehr
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nD. Quellenstatistik")
|
||||
artikel = [
|
||||
{"source": "France24", "source_url": "https://www.france24.com/en/1", "language": "en"},
|
||||
{"source": "France24", "source_url": "https://www.france24.com/en/2", "language": "en"},
|
||||
{"source": "El País", "source_url": "https://english.elpais.com/spain/1.html", "language": "de"},
|
||||
{"source": "tagesschau", "source_url": "https://www.tagesschau.de/1.html", "language": "de"},
|
||||
{"source": "n-tv", "source_url": "https://www.n-tv.de/1.html", "language": "de"},
|
||||
]
|
||||
zitiert = rg._prepare_sources({"sources_json": json.dumps([
|
||||
{"nr": 1, "name": "France24", "url": "https://www.france24.com/en/1"},
|
||||
{"nr": 2, "name": "France24", "url": "https://www.france24.com/en/2"},
|
||||
{"nr": 3, "name": "El País", "url": "https://english.elpais.com/spain/1.html"},
|
||||
{"nr": 4, "name": "tagesschau", "url": "https://www.tagesschau.de/1.html"},
|
||||
], ensure_ascii=False)})
|
||||
stats = rg._prepare_source_stats(zitiert, artikel)
|
||||
summe = sum(s["count"] for s in stats)
|
||||
pruefe("D1 Summe der Belege entspricht dem Verzeichnis", summe == len(zitiert), (summe, len(zitiert)))
|
||||
pruefe("D2 zwei France24-Belege ergeben eine Zeile",
|
||||
[s for s in stats if s["name"] == "France24"][0]["count"] == 2, stats)
|
||||
elpais = [s for s in stats if "Pa" in s["name"] or "País" in s["name"]]
|
||||
pruefe("D3 englische Ausgabe wird nicht als DE gefuehrt",
|
||||
elpais and elpais[0]["languages"] == "EN", elpais)
|
||||
# Weiterleitungen: vier Zeitungen liegen alle unter news.google.com. Sie
|
||||
# duerfen nicht zu einer Zeile verschmelzen und nicht den Namen tauschen.
|
||||
redirects = rg._prepare_sources({"sources_json": json.dumps([
|
||||
{"nr": 1, "name": "SZ.de", "url": "https://news.google.com/rss/articles/AAA?oc=5"},
|
||||
{"nr": 2, "name": "Kurier", "url": "https://news.google.com/rss/articles/BBB?oc=5"},
|
||||
{"nr": 3, "name": "Deutschlandfunk", "url": "https://news.google.com/rss/articles/CCC?oc=5"},
|
||||
], ensure_ascii=False)})
|
||||
pruefe("D6 Weiterleitungen behalten ihren eigenen Namen",
|
||||
[s["name"] for s in redirects] == ["SZ.de", "Kurier", "Deutschlandfunk"],
|
||||
[s["name"] for s in redirects])
|
||||
redirect_stats = rg._prepare_source_stats(redirects, [])
|
||||
pruefe("D7 drei Zeitungen bleiben drei Zeilen", len(redirect_stats) == 3, redirect_stats)
|
||||
pruefe("D8 Weiterleitung wird ausgewiesen",
|
||||
all("(Weiterleitung)" in s["name"] for s in redirect_stats), redirect_stats)
|
||||
|
||||
notiz = rg._source_stats_note(zitiert, artikel)
|
||||
pruefe("D4 Hinweis nennt zitierte Belege und ausgewertete Meldungen",
|
||||
"4 im Lagebild zitierten Belege" in notiz and "5 Meldungen" in notiz, notiz)
|
||||
pruefe("D5 Hinweis nutzt echte Umlaute", "zaehlt" not in notiz and "zählt" in notiz, notiz)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E. Das Template druckt die Quellennummer, nicht die Zeilennummer
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nE. Ausgabe im Bericht")
|
||||
env = Environment(loader=FileSystemLoader(str(rg.TEMPLATE_DIR)))
|
||||
template = env.get_template("report.html")
|
||||
html = template.render(
|
||||
incident={"title": "Testlage", "type": "adhoc"},
|
||||
incident_type_label="Live-Monitoring",
|
||||
report_date="01.08.2026, 20:39 Uhr",
|
||||
creator="test@aegis-sight.de",
|
||||
logo_base64="",
|
||||
executive_summary="",
|
||||
zusammenfassung_title="Neueste Entwicklungen",
|
||||
sections={"quellen"},
|
||||
scope="report",
|
||||
lagebild_html="",
|
||||
lagebild_timestamp="",
|
||||
sources=sources,
|
||||
fact_checks=[],
|
||||
source_stats=[{"name": "Medium 28", "count": 1, "languages": "DE"}],
|
||||
source_stats_note="Hinweistext zur Prüfung",
|
||||
timeline=[],
|
||||
articles=[],
|
||||
meta={},
|
||||
include_branding=True,
|
||||
)
|
||||
zeilen = [z for z in html.splitlines() if "medium28.de" in z]
|
||||
pruefe("E1 letzter Eintrag wird als 28 gedruckt, nicht als 9",
|
||||
zeilen and ">28<" in zeilen[0] and ">9<" not in zeilen[0], zeilen[:1])
|
||||
pruefe("E2 Statistikspalte heisst Belege", "<th>Belege</th>" in html)
|
||||
pruefe("E3 Hinweis unter der Statistik erscheint", "Hinweistext zur Prüfung" in html)
|
||||
pruefe("E4 alle Eintraege erscheinen, nichts wird gekappt",
|
||||
all(f"medium{n}.de" in html for n in [1, 2, 3, 20, 22, 24, 26, 27, 28]))
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
105
tests/test_rss_treffer.py
Normale Datei
105
tests/test_rss_treffer.py
Normale Datei
@@ -0,0 +1,105 @@
|
||||
"""Testet das Keyword-Matching der RSS-Auswertung.
|
||||
|
||||
Hintergrund: Dieselbe Lage lieferte am 01.08.2026 je nach Titel 79 oder 17
|
||||
RSS-Treffer. Ursache war die Kombination aus zwei Dingen. Die
|
||||
Keyword-Erzeugung lieferte fuer den politisch formulierten Titel Phrasen
|
||||
("migrationskrise spanien", "aufnahmeverfahren eu") statt Einzelbegriffe, und
|
||||
das Matching verglich diese Phrasen als starre Zeichenfolge. Die Schlagzeile
|
||||
"Migrationskrise in Spanien" enthaelt "migrationskrise spanien" nicht, wegen
|
||||
des Wortes dazwischen. Aus 53 Artikeln wurden so 16.
|
||||
|
||||
Laeuft ohne Netzzugriff und ohne Kosten.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
venv/bin/python tests/test_rss_treffer.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
from feeds.rss_parser import wort_trifft, _is_specific_word # noqa: E402
|
||||
from agents.researcher import FEED_SELECTION_PROMPT_TEMPLATE # noqa: E402
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=""):
|
||||
global ok, fail
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(" OK " + name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" FEHL " + name + " " + str(extra))
|
||||
|
||||
|
||||
SCHLAGZEILE = (
|
||||
"migrationskrise in spanien: eu-innenminister beraten am dienstag über ceuta"
|
||||
).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Einzelbegriffe verhalten sich wie bisher
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nA. Einzelbegriffe")
|
||||
pruefe("A1 enthaltenes Wort trifft", wort_trifft("ceuta", SCHLAGZEILE))
|
||||
pruefe("A2 nicht enthaltenes Wort trifft nicht", not wort_trifft("marokko", SCHLAGZEILE))
|
||||
pruefe("A3 Wortteil trifft weiterhin (Komposita)", wort_trifft("migration", SCHLAGZEILE))
|
||||
pruefe("A4 leeres Wort trifft nie", not wort_trifft("", SCHLAGZEILE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Phrasen treffen wortweise
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nB. Mehrwort-Begriffe")
|
||||
pruefe("B1 Phrase mit Wort dazwischen trifft jetzt",
|
||||
wort_trifft("migrationskrise spanien", SCHLAGZEILE))
|
||||
pruefe("B2 Phrase in umgekehrter Reihenfolge trifft",
|
||||
wort_trifft("spanien migrationskrise", SCHLAGZEILE))
|
||||
pruefe("B3 zusammenhaengende Phrase trifft weiterhin",
|
||||
wort_trifft("eu-innenminister beraten", SCHLAGZEILE))
|
||||
pruefe("B4 fehlt ein Bestandteil, trifft die Phrase nicht",
|
||||
not wort_trifft("migrationskrise portugal", SCHLAGZEILE))
|
||||
pruefe("B5 abstrakte Phrase ohne Bezug trifft nicht",
|
||||
not wort_trifft("aufnahmeverfahren eu", SCHLAGZEILE))
|
||||
|
||||
# Der gemeldete Fall: zehn Begriffe, davon neun Phrasen.
|
||||
KEYWORDS_LAGE_55 = [
|
||||
"migrationskrise spanien", "eu-innenminister", "südgrenze spanien",
|
||||
"mittelmeer migration", "grenzschutz europa", "flüchtlinge mittelmeer",
|
||||
"überfahrten mittelmeer", "schengen-außengrenzen", "europol migration",
|
||||
"aufnahmeverfahren eu",
|
||||
]
|
||||
treffer = sum(1 for w in KEYWORDS_LAGE_55 if wort_trifft(w, SCHLAGZEILE))
|
||||
alt_treffer = sum(1 for w in KEYWORDS_LAGE_55 if w in SCHLAGZEILE)
|
||||
pruefe("B6 der gemeldete Keyword-Satz trifft die Schlagzeile jetzt",
|
||||
treffer >= 2, (treffer, alt_treffer))
|
||||
pruefe("B7 vorher traf nur der eine Einzelbegriff", alt_treffer == 1, alt_treffer)
|
||||
|
||||
# Die Schwelle bleibt unveraendert streng: ohne spezifisches Wort im Text
|
||||
# braucht es mehrere Treffer.
|
||||
FREMDE = "fussball bundesliga spieltag zusammenfassung".lower()
|
||||
pruefe("B8 fremde Schlagzeile bleibt ohne Treffer",
|
||||
sum(1 for w in KEYWORDS_LAGE_55 if wort_trifft(w, FREMDE)) == 0)
|
||||
pruefe("B9 Spezifik-Erkennung unveraendert",
|
||||
_is_specific_word("migrationskrise spanien") and not _is_specific_word("eu"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. Der Auftrag verlangt Einzelbegriffe und Eigennamen
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\nC. Auftrag an die Keyword-Erzeugung")
|
||||
for stichwort, name in (
|
||||
("EINZELWÖRTER", "C1 Einzelwoerter statt Phrasen gefordert"),
|
||||
("Eigennamen der Lage MÜSSEN", "C2 Eigennamen sind Pflicht"),
|
||||
("Schauplatz", "C3 Schauplatz wird ausdruecklich genannt"),
|
||||
("KEINE abstrakten Politikbegriffe", "C4 abstrakte Begriffe ausgeschlossen"),
|
||||
):
|
||||
pruefe(name, stichwort in FEED_SELECTION_PROMPT_TEMPLATE)
|
||||
pruefe("C5 Beispiel nennt den konkreten Fehlerfall",
|
||||
"ceuta" in FEED_SELECTION_PROMPT_TEMPLATE.lower())
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
237
tests/test_sammelaktionen.js
Normale Datei
237
tests/test_sammelaktionen.js
Normale Datei
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Testet die Sammelaktionen der Seitenleiste ohne Browser.
|
||||
*
|
||||
* Die Methoden werden aus app.js herausgeloest und gegen ein schlankes
|
||||
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
|
||||
*
|
||||
* Aufruf aus dem Projektstamm:
|
||||
* node tests/test_sammelaktionen.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
function pruefe(name, bedingung, extra) {
|
||||
if (bedingung) {
|
||||
ok++;
|
||||
console.log(' OK ' + name);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(' FEHL ' + name + ' '
|
||||
+ (extra === undefined ? '' : JSON.stringify(extra)));
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM-Double, nur was die Sammelaktionen anfassen ---
|
||||
function element(id) {
|
||||
return {
|
||||
id: id,
|
||||
hidden: false,
|
||||
checked: false,
|
||||
textContent: '',
|
||||
style: {},
|
||||
_span: { textContent: '' },
|
||||
querySelector(sel) { return sel === 'span' ? this._span : null; },
|
||||
};
|
||||
}
|
||||
|
||||
const elemente = {};
|
||||
for (const id of ['sidebar-bulk-tools', 'sidebar-bulk-bar', 'sidebar-bulk-count',
|
||||
'sidebar-bulk-all', 'sidebar-bulk-archive', 'sidebar-bulk-activate',
|
||||
'sidebar-bulk-delete', 'empty-state', 'incident-view']) {
|
||||
elemente[id] = element(id);
|
||||
}
|
||||
|
||||
const meldungen = [];
|
||||
const aufrufe = { update: [], delete: [] };
|
||||
|
||||
const umgebung = {
|
||||
document: { getElementById: (id) => elemente[id] || null },
|
||||
UI: { showToast: (text, art) => meldungen.push({ text, art }) },
|
||||
API: {
|
||||
updateIncident: async (id, daten) => { aufrufe.update.push({ id, daten }); },
|
||||
deleteIncident: async (id) => { aufrufe.delete.push(id); },
|
||||
},
|
||||
confirm: () => umgebung._bestaetigen,
|
||||
_bestaetigen: true,
|
||||
console: console,
|
||||
};
|
||||
umgebung.window = umgebung;
|
||||
|
||||
// --- Die Sammelaktionen aus app.js herausloesen ---
|
||||
const quelle = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'static', 'js', 'app.js'), 'utf8');
|
||||
const von = quelle.indexOf(' // === Sammelaktionen in der Seitenleiste ===');
|
||||
const bis = quelle.indexOf(' renderSidebar() {', von);
|
||||
if (von === -1 || bis === -1) {
|
||||
console.log('FEHLER: Sammelaktionen in app.js nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kontext = vm.createContext(umgebung);
|
||||
vm.runInContext('var App = {\n' + quelle.slice(von, bis) + '\n'
|
||||
+ ' incidents: [], currentIncidentId: null, _sidebarFilter: "all",'
|
||||
+ ' _currentUsername: "ich",\n'
|
||||
+ ' renderSidebar() { this._renderBulkBar(); },\n'
|
||||
+ ' async loadIncidents() { this._geladen = (this._geladen || 0) + 1; },\n'
|
||||
+ '};', kontext);
|
||||
const App = kontext.App;
|
||||
|
||||
const LAGEN = [
|
||||
{ id: 1, title: 'Ceuta', status: 'active', article_count: 40, created_by_username: 'ich' },
|
||||
{ id: 2, title: 'IMK 2026', status: 'active', article_count: 20, created_by_username: 'wer' },
|
||||
{ id: 3, title: 'Altfall', status: 'archived', article_count: 5, created_by_username: 'ich' },
|
||||
];
|
||||
|
||||
function neu() {
|
||||
App.incidents = LAGEN.map(l => Object.assign({}, l));
|
||||
App._bulkMode = null;
|
||||
App._bulkSel = new Set();
|
||||
App._sidebarFilter = 'all';
|
||||
App.currentIncidentId = null;
|
||||
aufrufe.update.length = 0;
|
||||
aufrufe.delete.length = 0;
|
||||
meldungen.length = 0;
|
||||
umgebung._bestaetigen = true;
|
||||
for (const el of Object.values(elemente)) {
|
||||
el.hidden = false;
|
||||
el.checked = false;
|
||||
el.textContent = '';
|
||||
el.style = {};
|
||||
el._span.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nA) Der Auswahlmodus schaltet die Leisten um');
|
||||
neu();
|
||||
pruefe('A1 normal ist der Auswahlmodus aus', App._bulkMode === null);
|
||||
App.startBulkMode('archive');
|
||||
pruefe('A2 Startleiste verschwindet', elemente['sidebar-bulk-tools'].hidden === true);
|
||||
pruefe('A3 Bestaetigungsleiste erscheint', elemente['sidebar-bulk-bar'].hidden === false);
|
||||
pruefe('A4 Hinweis fordert zum Anhaken auf',
|
||||
elemente['sidebar-bulk-count'].textContent.includes('anhaken'),
|
||||
elemente['sidebar-bulk-count'].textContent);
|
||||
App.endBulkMode();
|
||||
pruefe('A5 Abbrechen stellt den Ausgangszustand her',
|
||||
App._bulkMode === null && elemente['sidebar-bulk-tools'].hidden === false);
|
||||
pruefe('A6 Auswahl wird dabei verworfen', App._bulkSel.size === 0);
|
||||
|
||||
console.log('\nB) Angeboten wird nur, was zur Auswahl passt');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
App.toggleBulkSel(1, true);
|
||||
pruefe('B1 Archivieren sichtbar bei aktiver Lage',
|
||||
elemente['sidebar-bulk-archive'].hidden === false);
|
||||
pruefe('B2 Aktivieren verborgen ohne archivierte Lage',
|
||||
elemente['sidebar-bulk-activate'].hidden === true);
|
||||
pruefe('B3 Loeschen im Archivmodus verborgen',
|
||||
elemente['sidebar-bulk-delete'].hidden === true);
|
||||
pruefe('B4 Zaehler im Knopf stimmt',
|
||||
elemente['sidebar-bulk-archive']._span.textContent === 'Archivieren (1)',
|
||||
elemente['sidebar-bulk-archive']._span.textContent);
|
||||
App.toggleBulkSel(3, true);
|
||||
pruefe('B5 Aktivieren erscheint bei archivierter Lage',
|
||||
elemente['sidebar-bulk-activate'].hidden === false);
|
||||
pruefe('B6 beide Zaehler stimmen',
|
||||
elemente['sidebar-bulk-archive']._span.textContent === 'Archivieren (1)'
|
||||
&& elemente['sidebar-bulk-activate']._span.textContent === 'Aktivieren (1)');
|
||||
pruefe('B7 Auswahl wird gezaehlt',
|
||||
elemente['sidebar-bulk-count'].textContent === '2 Lagen ausgewählt',
|
||||
elemente['sidebar-bulk-count'].textContent);
|
||||
neu();
|
||||
App.startBulkMode('delete');
|
||||
App.toggleBulkSel(1, true);
|
||||
pruefe('B8 im Loeschmodus nur Loeschen',
|
||||
elemente['sidebar-bulk-delete'].hidden === false
|
||||
&& elemente['sidebar-bulk-archive'].hidden === true);
|
||||
pruefe('B9 Zaehler im Loeschknopf',
|
||||
elemente['sidebar-bulk-delete']._span.textContent === 'Löschen (1)',
|
||||
elemente['sidebar-bulk-delete']._span.textContent);
|
||||
|
||||
console.log('\nC) Alle sichtbaren auswaehlen achtet auf den Filter');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
App.selectAllVisible(true);
|
||||
pruefe('C1 ohne Filter sind alle drei gewaehlt', App._bulkSel.size === 3, App._bulkSel.size);
|
||||
pruefe('C2 der Alle-Haken spiegelt das', elemente['sidebar-bulk-all'].checked === true);
|
||||
App.selectAllVisible(false);
|
||||
pruefe('C3 abwaehlen leert die Auswahl', App._bulkSel.size === 0);
|
||||
App._sidebarFilter = 'mine';
|
||||
App.selectAllVisible(true);
|
||||
pruefe('C4 mit Filter nur die eigenen', App._bulkSel.size === 2, [...App._bulkSel]);
|
||||
pruefe('C5 der fremde Fall fehlt', !App._bulkSel.has(2));
|
||||
|
||||
console.log('\nD) Archivieren wirkt nur auf aktive Lagen');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
App._bulkSel = new Set([1, 3]);
|
||||
await App.bulkStatus('archived');
|
||||
pruefe('D1 nur die aktive Lage wurde archiviert',
|
||||
aufrufe.update.length === 1 && aufrufe.update[0].id === 1, aufrufe.update);
|
||||
pruefe('D2 Status korrekt gesetzt',
|
||||
aufrufe.update[0].daten.status === 'archived', aufrufe.update[0]);
|
||||
pruefe('D3 Auswahlmodus endet danach', App._bulkMode === null);
|
||||
pruefe('D4 Rueckmeldung an den Nutzer',
|
||||
meldungen.length === 1 && meldungen[0].art === 'success', meldungen);
|
||||
|
||||
console.log('\nE) Aktivieren wirkt nur auf archivierte Lagen');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
App._bulkSel = new Set([1, 3]);
|
||||
await App.bulkStatus('active');
|
||||
pruefe('E1 nur die archivierte Lage wurde aktiviert',
|
||||
aufrufe.update.length === 1 && aufrufe.update[0].id === 3, aufrufe.update);
|
||||
|
||||
console.log('\nF) Loeschen fragt nach und raeumt auf');
|
||||
neu();
|
||||
App.startBulkMode('delete');
|
||||
App._bulkSel = new Set([1, 2]);
|
||||
umgebung._bestaetigen = false;
|
||||
await App.bulkDelete();
|
||||
pruefe('F1 ohne Bestaetigung wird nichts geloescht', aufrufe.delete.length === 0);
|
||||
pruefe('F2 der Auswahlmodus bleibt bestehen', App._bulkMode === 'delete');
|
||||
|
||||
umgebung._bestaetigen = true;
|
||||
App.currentIncidentId = 1;
|
||||
await App.bulkDelete();
|
||||
pruefe('F3 beide Lagen geloescht',
|
||||
aufrufe.delete.length === 2 && aufrufe.delete.includes(1), aufrufe.delete);
|
||||
pruefe('F4 die offene Lage wird geschlossen', App.currentIncidentId === null);
|
||||
pruefe('F5 die Leeransicht erscheint',
|
||||
elemente['empty-state'].style.display === 'flex'
|
||||
&& elemente['incident-view'].style.display === 'none',
|
||||
[elemente['empty-state'].style.display, elemente['incident-view'].style.display]);
|
||||
|
||||
console.log('\nG) Fehlschlaege werden gemeldet statt verschluckt');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
App._bulkSel = new Set([1]);
|
||||
const alteFunktion = umgebung.API.updateIncident;
|
||||
umgebung.API.updateIncident = async () => { throw new Error('kein Recht'); };
|
||||
await App.bulkStatus('archived');
|
||||
umgebung.API.updateIncident = alteFunktion;
|
||||
pruefe('G1 Fehlermeldung erscheint',
|
||||
meldungen.length === 1 && meldungen[0].art === 'error', meldungen);
|
||||
pruefe('G2 die betroffene Nummer steht darin',
|
||||
meldungen[0].text.includes('1'), meldungen[0].text);
|
||||
|
||||
console.log('\nH) Leere Auswahl loest nichts aus');
|
||||
neu();
|
||||
App.startBulkMode('archive');
|
||||
await App.bulkStatus('archived');
|
||||
await App.bulkDelete();
|
||||
pruefe('H1 keine Aufrufe ohne Auswahl',
|
||||
aufrufe.update.length === 0 && aufrufe.delete.length === 0);
|
||||
pruefe('H2 keine Rueckmeldung ohne Auswahl', meldungen.length === 0, meldungen);
|
||||
|
||||
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
204
tests/test_studio_struktur.py
Normale Datei
204
tests/test_studio_struktur.py
Normale Datei
@@ -0,0 +1,204 @@
|
||||
"""Prueft die Struktur der Oberflaechen ohne Browser.
|
||||
|
||||
Faengt genau die Fehler, die eine Syntaxpruefung durchlaesst. Ein onclick, das
|
||||
auf eine nicht vorhandene Methode zeigt. Ein Feld, das das Skript sucht, das
|
||||
im Dokument aber fehlt. Eine ID, die zweimal vergeben ist. Ein vergessener
|
||||
Cache-Buster.
|
||||
|
||||
Aufruf aus dem Projektstamm:
|
||||
python3 tests/test_studio_struktur.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
STAMM = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATIC = os.path.join(STAMM, "src", "static")
|
||||
|
||||
ok = 0
|
||||
fehl = 0
|
||||
|
||||
|
||||
def pruefe(name, bedingung, extra=None):
|
||||
global ok, fehl
|
||||
if bedingung:
|
||||
ok += 1
|
||||
print(f" OK {name}")
|
||||
else:
|
||||
fehl += 1
|
||||
print(f" FEHL {name}" + (f" {extra}" if extra is not None else ""))
|
||||
|
||||
|
||||
def lies(*teile):
|
||||
with open(os.path.join(STATIC, *teile), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
studio_html = lies("studio.html")
|
||||
studio_js = lies("js", "studio.js")
|
||||
dash_html = lies("dashboard.html")
|
||||
app_js = lies("js", "app.js")
|
||||
comp_js = lies("js", "components.js")
|
||||
style = lies("css", "style.css")
|
||||
studio_css = lies("css", "studio.css")
|
||||
|
||||
|
||||
def ids(html):
|
||||
return re.findall(r'\bid="([^"]+)"', html)
|
||||
|
||||
|
||||
def doppelte(liste):
|
||||
gesehen, doppelt = set(), []
|
||||
for x in liste:
|
||||
if x in gesehen:
|
||||
doppelt.append(x)
|
||||
gesehen.add(x)
|
||||
return doppelt
|
||||
|
||||
|
||||
print("\nA) IDs sind eindeutig")
|
||||
for name, html in [("studio.html", studio_html), ("dashboard.html", dash_html)]:
|
||||
d = doppelte(ids(html))
|
||||
pruefe(f"A keine doppelte ID in {name}", not d, d)
|
||||
|
||||
print("\nB) Der Einstellungen-Reiter ist vollstaendig verdrahtet")
|
||||
# Alle Felder, die die Logik anfasst, muessen im Dokument stehen.
|
||||
gesucht = sorted(set(re.findall(r"_setEl\('([^']+)'\)", studio_js)))
|
||||
vorhanden = set(ids(studio_html))
|
||||
fehlend = [f for f in gesucht if f not in vorhanden]
|
||||
pruefe("B1 jedes angesprochene Feld existiert", not fehlend, fehlend)
|
||||
pruefe("B2 es sind auch wirklich welche geprueft", len(gesucht) >= 15, len(gesucht))
|
||||
|
||||
# Panel und Navigationsknopf haengen am selben Schluessel.
|
||||
pruefe("B3 Navigationsknopf vorhanden",
|
||||
'data-art="settings" onclick="Studio.openTab(\'settings\')"' in studio_html)
|
||||
pruefe("B4 Panel vorhanden", '<div class="tab-panel" data-art="settings">' in studio_html)
|
||||
pruefe("B5 activateTab kennt den Reiter",
|
||||
"if (key === 'settings') this.loadSettings();" in studio_js)
|
||||
|
||||
# Bekannte tote Verweise aus der Domain-Sperrliste im Quellen-Modal. Alle
|
||||
# Zugriffe liegen hinter einer Existenzpruefung (showBlockDomainDialog kehrt
|
||||
# bei fehlendem Formular sofort zurueck), es droht also kein Absturz. Sie
|
||||
# werden gemeldet, zaehlen aber nicht als Fehler, damit die Pruefung nicht
|
||||
# dauerhaft rot steht und niemand mehr hinsieht.
|
||||
ALTLASTEN = {
|
||||
"block-domain-input", "block-domain-notes", "source-overview-chevron",
|
||||
"sources-block-form", "sources-hint",
|
||||
}
|
||||
|
||||
print("\nB2) Kein Zugriff auf Elemente, die es nicht gibt")
|
||||
# Ein getElementById auf ein entferntes Element scheitert still. Beim Umbau
|
||||
# der Bausteinspalte waere genau das leicht passiert.
|
||||
for name, js_quelle, html_quelle in [("studio", studio_js, studio_html),
|
||||
("dashboard", app_js, dash_html)]:
|
||||
vorhanden = set(re.findall(r'\bid="([^"]+)"', html_quelle))
|
||||
# Was das Skript selbst erzeugt, steht nicht im Dokument
|
||||
vorhanden |= set(re.findall(r"\.id = '([^']+)'", js_quelle))
|
||||
vorhanden |= set(re.findall(r'id="([^"]+)"', js_quelle))
|
||||
vorhanden |= set(re.findall(r'\bid="([^"]+)"', comp_js))
|
||||
gesucht = set(re.findall(r"getElementById\('([^']+)'\)", js_quelle))
|
||||
fehlend = sorted(g for g in gesucht if g not in vorhanden)
|
||||
neue = [g for g in fehlend if g not in ALTLASTEN]
|
||||
pruefe(f"B2 {name}.js greift nur auf vorhandene Elemente zu "
|
||||
f"({len(gesucht)} geprueft)", not neue, neue)
|
||||
bekannt = [g for g in fehlend if g in ALTLASTEN]
|
||||
if bekannt:
|
||||
print(f" bekannte Altlast, abgesichert: {', '.join(bekannt)}")
|
||||
|
||||
print("\nB3) Der Startdialog bietet nur echte Startaktionen")
|
||||
wahlen = re.findall(r"startChoice\('([a-z]+)'\)", studio_html)
|
||||
pruefe("B3a genau zwei Alternativen", len(wahlen) == 2, wahlen)
|
||||
pruefe("B3b aus dem Netz aufbauen oder mit eigenem Material",
|
||||
sorted(wahlen) == ["full", "ingest"], sorted(wahlen))
|
||||
pruefe("B3c die automatische Aktualisierung steht nicht mehr darin",
|
||||
"auto" not in wahlen and "sp-auto-desc" not in studio_html)
|
||||
pruefe("B3d und auch nicht mehr in der Logik",
|
||||
"kind === 'auto'" not in studio_js)
|
||||
pruefe("B3e sie ist aber im Einstellungen-Reiter erreichbar",
|
||||
'id="set-refresh-mode"' in studio_html and 'value="auto"' in studio_html)
|
||||
|
||||
print("\nC) Jedes onclick zeigt auf eine vorhandene Methode")
|
||||
for name, html, js, praefix in [
|
||||
("studio", studio_html, studio_js, "Studio"),
|
||||
("dashboard", dash_html, app_js + comp_js, "App"),
|
||||
]:
|
||||
aufgerufen = sorted(set(re.findall(praefix + r"\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", html)))
|
||||
fehlend = []
|
||||
for m in aufgerufen:
|
||||
# Methoden stehen als " name(" oder " async name(" im Objektliteral
|
||||
if not re.search(r"^\s+(async\s+)?" + re.escape(m) + r"\s*\(", js, re.M):
|
||||
fehlend.append(m)
|
||||
pruefe(f"C {name}.html ruft nur vorhandene Methoden ({len(aufgerufen)} geprueft)",
|
||||
not fehlend, fehlend)
|
||||
|
||||
print("\nD) Der KI-Weg ist in beiden Ansichten gleich angeboten")
|
||||
for name, html, feld in [("studio.html", studio_html, "set-ai-backend"),
|
||||
("studio.html anlegen", studio_html, "inc-ai-backend"),
|
||||
("dashboard.html", dash_html, "inc-ai-backend")]:
|
||||
block = re.search(r'<select id="' + feld + r'".*?</select>', html, re.S)
|
||||
pruefe(f"D {name} bietet drei Werte an",
|
||||
bool(block) and block.group(0).count("<option") == 3)
|
||||
if block:
|
||||
werte = re.findall(r'value="([^"]*)"', block.group(0))
|
||||
pruefe(f"D {name} genau die erlaubten Werte", werte == ["", "cli", "bedrock"], werte)
|
||||
|
||||
print("\nE) Cache-Buster sind einheitlich")
|
||||
for name, html in [("studio.html", studio_html), ("dashboard.html", dash_html)]:
|
||||
stempel = set(re.findall(r"/static/(?:js|css)/[a-z0-9_.-]+\?v=([0-9a-z]+)", html))
|
||||
# Fremde Bausteine (i18n, api, ws) haben eigene Staende, das ist in Ordnung.
|
||||
# Geprueft wird nur, dass die in diesem Zug geaenderten Dateien mitgezogen sind.
|
||||
eigene = re.findall(r"/static/(?:js|css)/(studio|app|style|components|studio)\.(?:js|css)\?v=([0-9a-z]+)", html)
|
||||
pruefe(f"E {name} nennt Stempel", bool(stempel), stempel)
|
||||
# Kein fester Wert. Geprueft wird, dass die in einem Zug geaenderten
|
||||
# Dateien denselben Stempel tragen, sonst muesste der Test bei jedem
|
||||
# Deploy mitgepflegt werden.
|
||||
stempel_eigen = {v for _, v in eigene}
|
||||
pruefe(f"E {name} alle eigenen Dateien tragen denselben Stempel",
|
||||
len(stempel_eigen) == 1, sorted(stempel_eigen))
|
||||
|
||||
print("\nE2) Die Bausteinspalte hat genau einen Startpunkt")
|
||||
_i = studio_html.index('id="studio-menu"')
|
||||
menue = studio_html[_i:studio_html.index("</section>", _i)]
|
||||
starts = re.findall(r'data-stage="([a-z]+)"', menue)
|
||||
pruefe("E2a genau ein Element startet einen Lauf", len(starts) == 1, starts)
|
||||
pruefe("E2b und zwar der komplette Lauf", starts == ["full"], starts)
|
||||
pruefe("E2c auch im ganzen Dokument nur dieses eine",
|
||||
len(re.findall(r"data-stage=", studio_html)) == 1,
|
||||
len(re.findall(r"data-stage=", studio_html)))
|
||||
pruefe("E2d die Ansichten starten nichts", "Studio.runStage" not in studio_html)
|
||||
for tot in ["art-card", "art-run", "art-note", "run-all", "collect-btn", "art-foot"]:
|
||||
pruefe(f"E2 {tot} ist restlos weg",
|
||||
tot not in studio_html and tot not in studio_js and tot not in studio_css)
|
||||
pruefe("E2e die Schritte tragen Ergebnisnamen",
|
||||
"Artikel holen" in studio_js and "Orte erkennen" in studio_js
|
||||
and "Fakten prüfen" in studio_js)
|
||||
pruefe("E2f Geoparsing steht nicht mehr in der Oberflaeche",
|
||||
"Geoparsing" not in studio_html)
|
||||
|
||||
print("\nF) Tags im neuen Panel sind ausgeglichen")
|
||||
i = studio_html.index('<div class="tab-panel" data-art="settings">')
|
||||
j = studio_html.index('<div class="tab-panel" data-art="export">')
|
||||
teil = studio_html[i:j]
|
||||
for tag in ["div", "select", "label", "textarea"]:
|
||||
auf = len(re.findall(r"<" + tag + r"[\s>]", teil))
|
||||
zu = len(re.findall(r"</" + tag + r">", teil))
|
||||
pruefe(f"F {tag} ausgeglichen ({auf})", auf == zu, (auf, zu))
|
||||
|
||||
print("\nG) Das Kennzeichen nutzt vorhandene Stile")
|
||||
pruefe("G1 incident-ai-badge ist definiert", ".incident-ai-badge" in style)
|
||||
pruefe("G2 case-ai-tag ist definiert", ".case-ai-tag" in studio_css)
|
||||
pruefe("G3 studio.html bindet style.css ein", "/static/css/style.css" in studio_html)
|
||||
pruefe("G4 Kennzeichenplatz in der Kopfzeile", 'id="studio-ai-badge"' in studio_html)
|
||||
|
||||
print("\nH) Keine unbekannten Design-Tokens")
|
||||
# var(--x, wert) ist auch ohne Definition gueltig, der Fallback greift.
|
||||
# Geprueft wird deshalb nur, was ohne Fallback benutzt wird.
|
||||
ohne_fallback = set(re.findall(r"var\((--[a-z0-9-]+)\s*\)", studio_css))
|
||||
definiert = set(re.findall(r"^\s*(--[a-z0-9-]+)\s*:", style, re.M))
|
||||
definiert |= set(re.findall(r"^\s*(--[a-z0-9-]+)\s*:", studio_css, re.M))
|
||||
unbekannt = sorted(ohne_fallback - definiert)
|
||||
pruefe("H1 jedes Token ohne Fallback ist definiert", not unbekannt, unbekannt)
|
||||
pruefe("H2 es sind auch wirklich welche geprueft", len(ohne_fallback) >= 10, len(ohne_fallback))
|
||||
|
||||
print(f"\nErgebnis: {ok} bestanden, {fehl} fehlgeschlagen")
|
||||
sys.exit(1 if fehl else 0)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren