Commits vergleichen

240 Commits

Autor SHA1 Nachricht Datum
Claude Code
f73c21235e feat(translator): Feature-Flag TRANSLATOR_ENABLED zum Abschalten (siehe main) 2026-05-03 20:43:40 +00:00
Claude Code
9078489d0a fix(orchestrator): Auto-Refresh nicht direkt nach Cancel/Error neu einreihen
- main.py: Auto-Refresh-Filter beruecksichtigt jetzt auch cancelled und error
- orchestrator.py: Queue-Cancels schreiben jetzt einen cancelled-Eintrag ins
  refresh_log via _log_queued_cancellation

Wirkung: Nach Cancel oder Error startet die Lage erst beim naechsten
regulaeren Slot wieder. refresh_mode bleibt unveraendert.

(Identisch zu Commit auf main, develop nachgezogen.)
2026-05-03 19:30:04 +00:00
24d7500152 Release-Notes: Übersichtlichere Navigation in der Seitenleiste 2026-05-03 17:21:37 +02:00
Claude Code
f0fe35b279 Sidebar Feedback-Button: mail-Icon (Brief) statt message-square 2026-05-03 15:14:59 +00:00
Claude Code
fb6e9fff19 Sidebar: Quellen+Feedback-Buttons mit Lucide-Icons + kuerzerem Text
Quellen verwalten -> Quellen (mit database-Icon)
Feedback senden  -> Feedback (mit message-square-Icon)
Tooltip behaelt den vollen Text fuer Mouseover.
2026-05-03 15:14:05 +00:00
Claude Code
b1a0e97a34 Pipeline: bei Lagen-Wechsel auf bereits-queued Lage automatisch beginQueue
Wenn der User in der Sidebar auf eine Lage klickt, die schon in Queue
wartet, ruft bindToIncident() die API auf und kriegt den letzten
gespeicherten Pipeline-Stand (alles done = gruen). Das ist falsch fuer
queued-Status.

Fix: nach API-Load pruefen, ob die Lage in App._refreshingIncidents ist
UND in UI._progressState mit step=queued -> beginQueue() selbst ausloesen.
Damit zeigt die Pipeline grau, sobald man auf die queued-Lage wechselt.
2026-05-03 14:27:20 +00:00
Claude Code
77797f6027 Refresh-Modal: Titel je nach Status (queued/cancelling/laeuft)
Bisher hing der Titel nur an state.isFirst -> stand auch "Aktualisierung
laeuft" wenn die Lage tatsaechlich noch in der Queue wartete.

Jetzt:
- queued    -> "In Warteschlange" (mit Position #N falls vorhanden)
- cancelling -> "Wird abgebrochen…"
- isFirst   -> "Erste Recherche laeuft"
- sonst     -> "Aktualisierung laeuft"
2026-05-03 14:18:17 +00:00
Claude Code
dc51ecafe8 Pipeline-Snapshot: Mini-Pipeline auch zuruecksetzen
beginQueue() und _restoreSnapshot() haben bisher nur _render() aufgerufen,
aber NICHT _renderMini(). Daher blieben die kleinen Pipeline-Icons im
"Aktualisierung laeuft"-Modal gruen, obwohl die Lage in Queue war.
Fix: an beiden Stellen auch _renderMini() aufrufen.
2026-05-03 14:15:27 +00:00
Claude Code
31fa17465a Pipeline-Icons: Snapshot/Restore bei Queue + Cancel
Vorher:
- Lage refreshen -> Lage geht in Queue, aber Pipeline-Icons bleiben gruen
  mit Haekchen vom letzten Refresh (suggeriert faelschlich "alles fertig")
- Cancel/Error -> Pipeline bleibt im Mix-Zustand (teils active, teils pending)

Nachher:
- pipeline.beginQueue(id): macht Snapshot des aktuellen _stateByKey und
  setzt alle Steps auf pending. Ausgeloest aus app.js handleRefresh()
  und _restoreRefreshingState() (auch nach F5).
- _onRefreshDoneSuccess: Snapshot verwerfen + API-Reload (wie bisher).
- _onRefreshDoneCancel: Snapshot zurueckspielen -> vorheriger gruener
  Stand sichtbar.
- _onRefreshDoneError: gleiches Verhalten wie Cancel.
- bindToIncident: Snapshot mitloeschen (lagen-spezifisch).
- Bei zweitem Refresh ohne Cancel dazwischen wird Snapshot bewusst
  ueberschrieben.
2026-05-03 14:10:56 +00:00
Claude Code
2a654cc882 AI-Disclaimer: Modell-Name (Claude/Anthropic) aus Text entfernt 2026-05-03 13:42:35 +00:00
Claude Code
6293cef91e Banner-Text + AI-Disclaimer-Modal + Translator-Robustheit
#28 Banner-Text bei Token-Budget aufgebraucht:
- middleware/license_check.py + static/js/app.js: Statt "Bitte Verwaltung
  kontaktieren" jetzt konkreter Upgrade-Pfad mit info@aegis-sight.de.

#29 AI-Hallucination-Disclaimer:
- Neue static/js/ai-disclaimer.js (analog zu update-system.js):
  IIFE-Modul, localStorage-versioniert (aegis_ai_disclaimer_seen=v1),
  inline-CSS mit Theme-Variablen, Modal mit Lucide-Info-Icon.
- Wird beim ersten Login einmalig gezeigt; ueber Header-User-Dropdown
  Eintrag "Ueber KI-Inhalte" jederzeit erneut oeffenbar.
- dashboard.html: Script-Tag + Dropdown-Button mit Lucide-SVG.
- style.css: kleiner Stil-Block fuer .header-dropdown-action.

Translator-Robustheit (Bonus):
- agents/translator.py: Parser akzeptiert jetzt auch von Claude wrapped
  Antworten ({{translations: [...]}}, {{items: [...]}}, einzelnes
  Object). Behebt Wrapper-Bug der gestern beim Backfill 75% der Calls
  fehlschlagen liess.
- Prompt deutlicher: "flaches JSON-Array, kein Wrapper".
2026-05-03 13:29:19 +00:00
Claude Code
a6f36be9c6 Translator-Agent: dedizierter Haiku-Pass fuer fehlende DE-Uebersetzungen
Bisher haben translations als Teil der Analyzer-JSON-Antwort gelebt
("translations": [...]). Bei vielen Artikeln pro Refresh hat das LLM die
Translations regelmaessig weggelassen (Output-Token-Druck), insbesondere
content_de (lange Texte werden zuerst gestrichen). Folge: viele englische
Artikel ohne deutsche Headline/Inhalt im Frontend.

Aenderungen:
- Neuer Agent src/agents/translator.py:
  * translate_articles_batch / translate_articles
  * Nutzt CLAUDE_MODEL_FAST (Haiku) - billig
  * Batch-Size 5 (mit Reserve gegen Output-Truncate)
  * Robustes JSON-Parsing: Markdown-Codefence, Truncate-Fallback,
    extrahiert auch unvollstaendige Antworten
  * Idempotent: Caller filtert auf fehlende headline_de/content_de
- analyzer.py: translations aus 4 Prompt-Templates entfernt (adhoc/research
  x analyze/enhance) und Fallback-Return-Dict bereinigt -> Analyzer-Output
  wird kompakter und zuverlaessiger
- orchestrator.py:
  * Alter Translation-INSERT-Block entfernt (analysis.translations wird
    nicht mehr genutzt)
  * Nach Analyse + db.commit + cancel-check neuer Translator-Call:
    SELECT WHERE language!=de AND (headline_de OR content_de fehlt),
    translate_articles, normalize_german_umlauts, COALESCE-UPDATE
  * Vor post_refresh_qc -> normalize_umlaut_articles greift auch frische
    Uebersetzungen
  * Failure-tolerant: Translator-Fehler bricht Refresh nicht ab

Backfill: migrations/migrate_translations_2026-05-03.py im Verwaltungs-Repo.
2026-05-03 00:04:59 +00:00
Claude Code
98c9da64b0 Umlaut-Normalisierung an drei Stellen + auch articles im QC
Fix fuer ASCII-Umlaute in Headlines/Inhalten (Gespraeche statt Gespraeche).
Zwei Quellen des Problems:
1. Quellen wie dpa-AFX, Telegram TASS/RIA liefern Headlines schon ASCII-fiziert
2. LLM-Uebersetzungen drift en gelegentlich zu ae/oe/ue trotz Prompt

Aenderungen:
- rss_parser.py: nach html_to_text auch normalize_german_umlauts auf
  title und summary anwenden (sicher, hunspell-Dict ignoriert englische
  Woerter wie Boeing/Business)
- orchestrator.py:1418 Translation-INSERT: headline_de und content_de
  durch normalize_german_umlauts schicken (LLM-Drift abfangen)
- post_refresh_qc.py: neue Funktion normalize_umlaut_articles als Sicher-
  heitsnetz analog zu normalize_umlaut_fields. Behandelt headline_de und
  content_de aller Artikel des Incidents; bei language=de zusaetzlich
  headline und content_original. Wird in run_post_refresh_qc nach
  normalize_umlaut_fields aufgerufen.

Backfill: migrations/migrate_umlauts_2026-05-03.py (im Verwaltungs-Repo)
2026-05-02 23:26:19 +00:00
Claude Code
307f0a1868 RSS-Parser: HTML aus summary strippen vor Speicherung
Ursache des Bugs: feedparser.entry.summary liefert bei vielen Quellen
(Guardian, AP, Sueddeutsche, Golem, Bellingcat, ...) HTML-kodierten Text
(<p>, <a>, <ul>, ...). Der Parser hat diesen 1:1 in articles.content_original
und content_de gespeichert. Folge:
- UI rendert HTML-Tags als Text in Timeline-Karten
- KI-Agenten (analyzer, entity_extractor, factchecker) bekommen HTML-Muell
  als Analyse-Input -> schwaechere Ergebnisse
- _is_german-Sprachheuristik wird durch Tags verzerrt
- 1000-Zeichen-Cap wird durch Tags + Tracking-URLs verbraucht

Fix: html_to_text aus feeds/transcript_extractors/_common.py wiederverwenden,
strippt Tags + decodiert HTML-Entities (inkl. dt. Umlaute) + normalisiert
Whitespace. Wird auf summary direkt nach entry.get angewandt -> betrifft
sowohl Match-Logik (text-Variable) als auch INSERT (content_original/de).

Backfill-Migration: migrations/migrate_html_strip_2026-05-03.py im
Verwaltungs-Repo, behandelt bestehende DB-Eintraege rueckwirkend.
2026-05-02 23:13:32 +00:00
Claude Code
430541f49b STAGING_MODE Env-Flag: kein Hard-Stop, kein Org-Switcher in Staging
Wenn STAGING_MODE=1 (oder true/yes) in der .env gesetzt ist:
- check_license() liefert immer unlimited_budget=True -> kein Token-Budget-Hard-Stop,
  egal was in der DB steht.
- /api/auth/me liefert is_global_admin=False -> Frontend ruft _initOrgSwitcher
  nicht auf, Org-Switcher-Section bleibt versteckt.

Nur in ~/AegisSight-Monitor-staging/.env gesetzt; Live-.env hat das Flag
nicht, daher dort unverändertes Produktiv-Verhalten.
2026-05-02 22:51:27 +00:00
Claude Code
ee83f38edf Token-Budget Hard-Stop + Banner bei aufgebrauchtem Budget
- check_license() liefert jetzt unlimited_budget, credits_total, credits_used,
  read_only_reason. Bei nicht-unlimited UND credits_used >= credits_total wird
  status=budget_exceeded, read_only=True gesetzt.
- require_writable_license blockiert mit 403 + X-License-Status-Header je nach Reason.
- /api/auth/me liefert read_only_reason und unlimited_budget; credits_percent_used
  wird nicht mehr auf 100 gekappt (echte Prozente).
- Frontend: Banner-Text dynamisch je nach reason (budget_exceeded/expired/...).
  Refresh-Button bei read_only deaktiviert + Tooltip. Globaler 403-Handler in
  api.js: bei X-License-Status -> Banner + Toast aktualisieren.
2026-05-02 20:16:25 +00:00
2b1e8c3632 requirements.txt: Export-Pakete dokumentiert
Jinja2, weasyprint und python-docx waren auf Live manuell ins venv
installiert, fehlten aber in requirements.txt — Folge: auf Staging waren
sie nicht installiert, Bericht-Export warf 500 (ModuleNotFoundError).
Jetzt im Repo dokumentiert, beim Aufsetzen neuer Umgebungen ist alles
vollständig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:34:58 +02:00
b1f8113207 Bericht-Export: drei Verbesserungen
1. Faktencheck immer vollständig
   PDF-Export hatte im scope=report einen [:20]-Cap, der vollständige
   Faktencheck wurde nur bei scope=full gerendert. Jetzt ungekürzt
   überall, sortiert chronologisch absteigend (DB-Sortierung).

2. Status-Labels aus Frontend übernommen
   FC_STATUS_LABELS hatte nur 4 Werte; in der DB existieren aber 7+
   (confirmed/unconfirmed/contradicted/developing/established/
   unverified/disputed). Folge: "contradicted" und drei weitere
   wurden auf englisch ausgegeben. Jetzt 1:1 vom Monitor-UI:
     contradicted → "Widerlegt"
     developing   → "Unklar"
     established  → "Gesichert"
     unverified   → "Ungeprüft"

3. Adhoc-Export: Neueste Entwicklungen statt Executive Summary
   Bei Live-Monitoring-Lagen ist die generische Executive Summary
   weniger aussagekräftig als die kompakten "Neueste Entwicklungen"-
   Bullets. Endpoint nutzt jetzt:
     - adhoc + latest_developments vorhanden → latest_developments
       (Markdown -> HTML konvertiert)
     - adhoc + leer → cached/generierte Executive Summary (Fallback)
     - research → unverändert Executive Summary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:32:36 +02:00
26fac0e824 Analysepipeline: Reset auf "pending" beim Refresh-Start
Beim ersten Schritt (sources_review) eines neuen Refreshs werden alle
nachfolgenden Schritte sichtbar auf "pending" (grau) zurückgesetzt.
Vorher hingen sie weiterhin als "done" vom letzten Refresh in grün
herum, während die Pipeline schon einen neuen Durchlauf zeigte.

- Bedingung in pipeline.js entschärft: nicht mehr nur bei
  pass_number > 1 (Multi-Pass), sondern bei jedem ersten Schritt-Active
- Bei Reset wird das ganze Stage neu gezeichnet (nicht nur der einzelne
  Block), damit die zurückgesetzten Schritte tatsächlich grau erscheinen
- Greift sowohl bei normalem Refresh als auch bei Multi-Pass-Wechsel
  einer Research-Lage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:06:06 +02:00
62c0be64ee Analysepipeline: Reihenfolge "Fakten prüfen" vor "Lagebild verfassen"
Reihenfolge in der Pipeline-Anzeige getauscht — passt zur perspektivischen
Backend-Umstellung (Faktencheck-Output soll als Kontext ins Lagebild
einfließen, statt parallel zu generieren). Backend läuft aktuell noch
parallel; sobald die sequenzielle Variante mit Kontext-Übergabe steht,
stimmt die Anzeige mit dem realen Flow überein.

Im 3x3-Snake-Layout liegt jetzt:
  Reihe 2: Relevanz bewerten → Orte erkennen → Fakten prüfen
  Reihe 3: Lagebild verfassen → Qualitätscheck → Benachrichtigen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:59:46 +02:00
8c4ef6b2cf CATEGORY_REPUTATION: Schlüssel an aktuelle DB-Werte angepasst
Die Reputation-Map nutzte veraltete Schlüssel (presseagenturen,
behoerden, nachrichten_de/int), die nirgends in der DB vorkamen — die
DB hat nachrichtenagentur, behoerde, oeffentlich-rechtlich,
qualitaetszeitung, think-tank, regional, telegram, boulevard. Folge
war ein stiller Bug: alle hochwertigen Quellen (Reuters, ZDF,
tagesschau, Spiegel, FAZ, BMI etc.) bekamen den Default-Score 0.4 wie
"sonstige" und wurden in der Relevanz-Sortierung nicht bevorzugt.

Map jetzt vollständig auf aktuelle Kategorie-Werte:
- nachrichtenagentur, behoerde:    1.00
- oeffentlich-rechtlich:           0.95
- qualitaetszeitung, think-tank:   0.85
- fachmedien:                      0.80
- international:                   0.75
- regional:                        0.65
- telegram:                        0.50
- sonstige:                        0.40
- boulevard:                       0.30

Test mit 200 zufälligen Artikeln aus der Live-DB:
155 besser bewertet, 0 schlechter, 45 unverändert.
Stärkster Effekt bei ÖR (+0.165), Nachrichtenagenturen (+0.18),
Qualitätszeitungen (+0.135).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:52:02 +02:00
ad5b723d79 Quellenübersicht: Lagebild-Quellennummer [N] statt fortlaufender Nummer
Statt einer eigenen Nummerierung (1., 2., ...) wird jetzt die echte
Lagebild-Quellennummer im Format [N] angezeigt — also exakt das, was im
Lagebild-Text als Zitat erscheint. Match per exakter source_url, mit
Quellen-Name als Fallback.

Artikel ohne Match (nicht im Lagebild zitiert) bekommen einen dezenten
Strich "—" mit Tooltip "Nicht im Lagebild zitiert", damit sichtbar ist
welche Belege Claude überhaupt verwendet hat und welche nicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:04:52 +02:00
51615cae62 Quellenübersicht: Detail-Liste mit Nummer, Datum und Link
Aufklapp-Liste pro Quelle zeigt jetzt:
1. fortlaufende Nummer (gold, monospace)
2. Datum + Uhrzeit (klein, dezent grau, monospace)
3. Headline als Link zum Originalartikel

Drei-Spalten-Grid (Nummer | Datum | Headline). Auf schmalem Viewport
(<600px) klappt das Datum unter die Nummer. Bei research-Lagen wird
published_at bevorzugt, sonst collected_at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:01:06 +02:00
a2610d0094 Quellenübersicht: Klick auf Quelle klappt Artikel-Liste auf
Quellen-Boxen waren bisher reine Anzeige. Jetzt sind sie klickbar:
beim Klick erscheint direkt unter der Box (über die volle Grid-Breite)
eine Liste der Artikel-Headlines dieser Quelle, jede mit Link zum
Originalartikel. Mutual-exclusive — Klick auf eine andere Quelle
schließt die vorherige automatisch.

- components.js: Item bekommt data-source, onclick + Tastatur-Support
  (Enter/Space), aria-expanded.
- app.js: toggleSourceOverviewDetail filtert _currentArticles nach
  Quelle, sortiert chronologisch absteigend, fügt das Detail-Element
  via insertAdjacentElement direkt nach dem geklickten Item ein.
- CSS: aktiver Item-Status (Glow + Tint), Detail-Block mit
  grid-column 1/-1 (volle Breite) + max-height 320px scrollbar bei
  vielen Artikeln + dezente Slide-In-Animation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:57:48 +02:00
a08df3d121 RSS-Parser: Match-Schwelle adaptiv (Bug 1 aus Buckelwal-Diagnose)
Bisher musste eine Headline mindestens 2 der dynamisch generierten
Suchworte enthalten, um den Match-Filter zu passieren. Bei thematisch
engen Lagen (Bsp. "Buckelwal timmy") fielen damit echte Treffer wie
"Transport mit Buckelwal erreicht dänische Gewässer..." durch, weil
nur 1 Keyword (buckelwal) gematcht hat.

Neue Heuristik: enthält der Text mindestens ein spezifisches Keyword
(>=7 Zeichen, also keine kurzen Akteursnamen wie "iran" oder "trump"),
reicht 1 Treffer. Bei nur kurzen, generischen Keywords gilt weiter die
alte Schwelle (halb der Wörter, max. 2). Topic-Filter danach (Haiku)
fängt False Positives.

Damit kommen ZDF/tagesschau/n-tv-Headlines mit nur einem starken
Begriff durch — der Hauptgrund, warum Lage 8 Buckelwal mit ZDF-Quelle
am ersten Refresh 0 Artikel hatte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:55:05 +02:00
0a6208c289 WebSearch: eingetragene Web-Quellen via Haiku vorselektieren
Bisher hatten Quellen vom Typ web_source keine praktische Wirkung auf
die Recherche - sie lagen nur als Marker in der DB. Jetzt werden sie
aktiv in den Recherche-Prompt eingebunden.

Ablauf:
1. Vor dem Hauptaufruf an Opus prüft ein günstiger Haiku-Call alle
   aktiven Web-Quellen des Tenants (plus globale) und wählt die
   thematisch passenden aus. Leere Selektion ist ausdrücklich erlaubt.
2. Die ausgewählten Domains werden dem Recherche-Prompt als
   "EINGETRAGENE WEB-QUELLEN" Block beigegeben mit der Empfehlung,
   gezielt mit "site:domain query" zu suchen, falls thematisch passend.
3. site: ist Empfehlung, kein Zwang - Claude bleibt flexibel und
   ergänzt seine sonstige Recherche.

- source_rules.get_feeds_with_metadata: SELECT um notes-Feld erweitert,
  damit der Selektor besseren Kontext zur Quelle hat.
- ResearcherAgent.select_relevant_web_sources: neuer Helper analog zu
  select_relevant_feeds, mit Skip-Optimierung wenn ≤3 Quellen.
- WEB_SOURCE_SELECTION_PROMPT: explizite Regel "lieber leer als
  pauschal alle", verhindert Token-Verschwendung.
- ResearcherAgent.search: neuer Parameter preferred_sources, beide
  Templates (RESEARCH + DEEP_RESEARCH) bekommen optionalen
  preferred_sources_block.
- Orchestrator._web_search_pipeline: Vorselektion vor researcher.search,
  Token-Usage in usage_acc, Logging der gewählten Domains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:45:17 +02:00
19038472cf Ereignis-Timeline: ▼-Pfeil unter aktivem Heatmap-Balken entfernt
Der Pfeil überschattete das darunter liegende Stunden-Label. Goldener
Balken mit Glow + scaleY reicht als visuelles Aktiv-Signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:06:31 +02:00
462127dc52 Ereignis-Timeline: Heatmap-Klick-Bug beheben
Inline-onclick mit JSON.stringify(label) + UI.escape erzeugte bei
Bucket-Labels mit Anführungszeichen oder Sonderzeichen einen kaputten
HTML-Attribut-String. Klicks lösten daher gar keinen Handler aus.

Statt JS-String im onclick werden Bucket-Daten jetzt als
data-start/data-end/data-label-Attribute am Cell-Element gehalten.
Onclick ruft App.handleStripClick(this), das die Werte sauber aus
dataset liest und an openTimelineWindow weiterreicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:04:02 +02:00
34aeb04a88 Ereignis-Timeline: Klick auf Heatmap-Balken filtert den Stream
Vorher scrollte ein Klick auf einen Balken nur zur passenden Zeit-
Gruppe — bei langem Stream kaum erkennbar. Jetzt filtert der Klick
den Stream auf das Zeitfenster des Balkens und zeigt nur diese
Einträge.

- Aktiver Balken: vergrößert (scaleY 1.6) + goldener Hintergrund +
  starker Glow + kleiner ▼-Pfeil darunter; alle anderen Balken auf
  40% Opacity gedimmt.
- Banner zwischen Strip und Stream zeigt "Gefiltert auf [Label] ·
  X Einträge" mit "Filter aufheben"-Button.
- Zweiter Klick auf denselben Balken oder Banner-Button hebt den
  Filter auf.
- Filter/Range-Buttons setzen den Strip-Window-Filter zurück (sonst
  inkonsistente Doppel-Filterung).
- Lagen-Wechsel räumt _activeStripWindow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:59:32 +02:00
b14fe31f42 Ereignis-Timeline: Newsfeed mit Lagebericht-Sektionen + Heatmap-Strip
Komplett neu gedacht: nicht mehr horizontale Karten-Kette, sondern
vertikaler Newsfeed mit den vorhandenen vt-Klassen, plus dezenter
Heatmap-Strip oben für die Quantitäts-Übersicht.

- Heatmap-Strip oben (14 px hoch): ein Quadrat pro Tag/Stunde/Woche/
  Monat je nach Spannweite, Farbintensität = Aktivität, goldener
  Boden-Strich bei Lagebericht.
- Klick auf Heatmap-Quadrat: Stream scrollt zur passenden Zeit-Gruppe,
  diese flasht kurz auf.
- Newsfeed darunter: vt-time-group mit Datums-Trennzeilen
  (Heute/Gestern/...), Lagebericht-Einträge sind durch ihre vt-snapshot
  Klasse prominent gegenüber Meldungs-Einträgen.
- Klick auf Lagebericht: Volltext klappt inline auf (vorhandener
  lazyLoadSnapshotDetail-Mechanismus, kein separates Detail-Panel mehr).
- Klick auf Meldung: Detail klappt inline auf.

Karten-Kette, Verbindungs-Stränge, "Aktuell"-Marker, Snapshot-Detail-
Panel, Window-Detail-Panel und alle zugehörigen CSS-Klassen
(ht-card/ht-link/ht-now/ht-chain/ht-detail) komplett entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:41 +02:00
ffb8dddc4f Ereignis-Timeline: Snapshot-zentriertes Konzept
Komplette Neufassung der horizontalen Timeline. Lageberichte sind die
natürlichen Anker einer OSINT-Lage; Artikel werden um sie herum
gruppiert.

Aufbau:
- Quanti-Strip oben: schmale Heatmap-Reihe (ein Quadrat pro Stunde/Tag/
  Woche/Monat je nach Spannweite), Farbintensität = Aktivität. Quadrate
  mit Lagebericht haben goldene Unterkante. Klick auf Quadrat öffnet
  Detail-Panel mit allen Meldungen des Zeitfensters.
- Lagebericht-Kette darunter: jede Karte zeigt Datum, Vorschau-Text aus
  dem Snapshot, Anzahl Meldungen + Fakten. Karten sind durch Stränge
  verbunden, die "X Meldungen"-Pille tragen — Klick auf Strang öffnet
  Liste der Meldungen zwischen den beiden Lageberichten.
- "Aktuell"-Marker am rechten Ende mit pulsierendem Pin.

Filter:
- Alle: Strip + Kette
- Meldungen: Strip + vertikaler Stream
- Lageberichte: nur Karten ohne Strip/Stränge

Edge-Case: Lagen ohne Lagebericht zeigen Strip + Stream als Fallback.

Mobile (<900px): Kette stapelt vertikal, Strip bleibt horizontal.

Alte Bar-Achse, Punkte, Bucket-Merge, Day-Markers etc. komplett
entfernt — die alte Achse war für sporadische OSINT-Aktivität das
falsche Pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:38:09 +02:00
AegisSight Promote-UI
0edbf7e3b8 Revert "Ereignis-Timeline: Säulen, Lagebericht-Linien, Themen-Labels"
This reverts commit 370bb94b26.
2026-05-01 15:22:13 +02:00
AegisSight Promote-UI
de01ab71fc Revert "Ereignis-Timeline: Überlappungen oben auflösen"
This reverts commit 58eb1298ca.
2026-05-01 15:22:06 +02:00
AegisSight Promote-UI
86a49e082c Revert "Ereignis-Timeline: Lagebericht-Stempel zusammenfassen, Bar-Cap entfernen"
This reverts commit cae9c5467a.
2026-05-01 15:21:53 +02:00
AegisSight Promote-UI
221b21cb4e Revert "Cache-Bust: style.css und app.js Versionen erhöht"
This reverts commit 30cb276ec6.
2026-05-01 15:21:52 +02:00
30cb276ec6 Cache-Bust: style.css und app.js Versionen erhöht
Browser hatten die alten Timeline-Stile gecached und Änderungen waren
nicht sichtbar. Versions-Suffixe auf 20260501a aktualisiert, damit der
Cache zwingend invalidiert wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:15:55 +02:00
cae9c5467a Ereignis-Timeline: Lagebericht-Stempel zusammenfassen, Bar-Cap entfernen
Mehrere Snapshots in derselben Achsen-Position erzeugten verschmierte,
übereinanderliegende Pin-Symbole. Zusätzlich war der goldene Streifen
auf der Bar (Bar-Cap) redundant zur vertikalen Snapshot-Linie.

- Snapshots werden pro Achsen-Position (auf 0,5%-Genauigkeit) gruppiert.
  Eine einzige Linie + ein einziger Pin pro Gruppe; bei mehreren
  Lageberichten zeigt der Pin die Anzahl als Zahl statt das Stempel-
  Symbol.
- Bar-Cap (separates Element über der Bar) entfernt. Stattdessen
  bekommt die Bar-Füllung bei has-snapshot eine dezente goldene
  Top-Linie via ::before — keine Doppel-Markierung mehr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:13:17 +02:00
58eb1298ca Ereignis-Timeline: Überlappungen oben auflösen
Im Top-Bereich der Achse kollidierten Tagesmarker, Themen-Labels und
Lagebericht-Stempel auf der gleichen Höhe. Jetzt klare Schichten:

- Tagesmarker (Heute/Gestern/Datum): top 0
- Themen-Labels: eigene Schiene direkt darunter (top 22 / 42 hourly),
  nicht mehr Kind der Bar — verhindert Wandern bei verschieden hohen
  Bars
- Bars: nach unten verschoben (top 44 / 64 hourly)
- Lagebericht-Linien: gehen jetzt nur durch den Bar-Bereich,
  Pin-Symbol (Cap) hängt UNTEN an der Achsenlinie statt oben in den
  Tagesmarkern
- Heute-Linie: bei stündlicher Granularität ausgeblendet (Tagesmarker
  zeigt eh "Heute, ..."), bei Tag/Woche/Monat weiterhin aktiv

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:10:16 +02:00
370bb94b26 Ereignis-Timeline: Säulen, Lagebericht-Linien, Themen-Labels
Punkte ersetzt durch schmale Säulen (Bar-Chart), Höhe = Anzahl Artikel
im Bucket relativ zum Maximum. Aktivität ist sofort als Verlauf lesbar.

- Granularität: hour < 48h, day < 30T, week < 180T, sonst month.
  Bucket-Merge (verfälscht das Datum) entfernt, stattdessen sauberer
  Granularitätswechsel.
- Lagebericht-Linien quer durch die Achse als dezente goldene Vertikalen
  mit kleinem Stempel-Symbol oben. Klick öffnet das Bucket-Detail mit
  dem zugehörigen Snapshot.
- Heute-Linie mit Label, wenn der heutige Zeitpunkt im sichtbaren
  Bereich liegt.
- Themen-Label über den Top-3 aktivsten Buckets: clientseitig per
  Wort-Häufigkeit aus Headlines, mit deutscher Stopwortliste. Zeigt
  nur, wenn ein Wort mindestens zweimal vorkommt.
- Hover über eine Säule: Mini-Karte mit den 3 relevantesten Headlines
  des Buckets (sortiert nach relevance_score), plus "+N weitere" und
  Lagebericht-Hinweis bei gemischten Buckets.
- Snapshot-Bars bekommen oben einen goldenen Cap als Marker.
- Reduced-motion respektiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:04:43 +02:00
c9bd6310ae Analysepipeline: Snake-Track mittig zentrieren statt 100% breit
Track war 100% breit, dadurch saß die ltr-Reihe links und die rtl-Reihe
rechts in der Karte. Block 3 (Ende Reihe 1) und Block 4 (Anfang Reihe 2)
lagen weit auseinander, der Reihenwechsel-Pfeil wirkte zusammenhanglos.

Track ist jetzt inline-flex (schrumpft auf Inhaltsbreite, ca. 3 Blöcke
plus Lücken) und wird in der Karte zentriert. Alle drei Reihen sind
gleich breit, Block 3 sitzt direkt über Block 4, die Snake-Form wird
optisch nachvollziehbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:38:19 +02:00
392028a9aa Analysepipeline: kompakter Reihenwechsel-Pfeil statt langem Bogen
Der U-Turn-Bogen, der quer ueber die ganze Box-Breite lief, wirkte bei
nur drei Bloecken pro Reihe optisch ueberladen. Jetzt sitzt unter dem
letzten Block der oberen Reihe ein schlichter, kurzer Pfeil nach unten,
der direkt zum ersten Block der naechsten Reihe zeigt.

- pipeline.js: Neue _renderUturn-Variante, die Spacer (Block-Breite)
  vor oder hinter den Pfeil setzt, sodass er passgenau unter dem letzten
  Block sitzt (rechts nach ltr-Reihe, links nach rtl-Reihe).
- style.css: Pfeil-Container nutzt Flex mit Block-Breite-Spacern statt
  100%-breitem SVG. Kurzer ↓ als gerader Pfad mit Pfeilkopf,
  is-flowing-Animation bleibt erhalten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:35:13 +02:00
7b5adccf2b Analysepipeline: echte Umlaute und ASCII-Bindestriche
Em-dashes und Umlaut-Umschreibungen aus den Pipeline-Aenderungen
entfernt: Tooltip-Texte, HTML-Empty-State, JS-Kommentare,
Count-Status-Platzhalter, Orchestrator-Kommentare und CSS-Kommentare.

Anstelle von typografischen Gedankenstrichen werden jetzt Kommas oder
Punkte gesetzt, "uebersprungen" -> "uebersprungen" mit echtem Umlaut,
"laeuft" usw. analog. UI-Text "&mdash; Refresh starten" wird zu zwei
Saetzen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:16:28 +02:00
059a9a2dc7 Analysepipeline: Snake-Layout (3x3) statt linearer Reihe
Pipeline laeuft jetzt zickzack: Reihe 1 von links nach rechts, U-Turn
nach unten, Reihe 2 von rechts nach links, U-Turn nach unten,
Reihe 3 wieder von links nach rechts. Karte waechst auf benoetigte
Hoehe statt horizontalem Scrollen.

- pipeline.js: Bloecke werden in Dreier-Gruppen aufgeteilt, Direction
  ltr/rtl wechselt pro Reihe. Zwischen Reihen rendert ein SVG-U-Turn-Pfeil
  (Bogen mit Pfeilkopf) die Verbindung. Daten-Fluss-Animation (is-flowing)
  funktioniert sowohl auf Inner-Pfeilen als auch auf U-Turns.
- CSS: .pipeline-row mit flex-direction abhaengig von data-direction.
  rtl-Reihen kippen Pfeilkopf und Animation in entgegengesetzte Richtung.
  U-Turn-Pfad als SVG mit stroke-dasharray-Animation bei aktivem Fluss.
- Mobile (<900px): Snake aufgeloest, alle Reihen werden vertikal
  gestapelt, U-Turns ausgeblendet — bestehende Vertikal-Stilistik bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:07:58 +02:00
3a346ba2ec Analysepipeline: Visualisierung der Refresh-Schritte
Neuer Tab "Analysepipeline" zwischen Faktencheck und Quellenuebersicht.
Zeigt 9 Verarbeitungsschritte als n8n-artige Blockkette: Quellen sichten,
Nachrichten sammeln, Doppeltes filtern, Relevanz bewerten, Orte erkennen,
Lagebild verfassen, Fakten pruefen, Qualitaetscheck, Benachrichtigen.

- Backend: refresh_pipeline_steps-Tabelle persistiert pro Refresh+Pass die
  Status- und Zahlen-Werte. pipeline_tracker.py kapselt Start/Done/Skip/Error
  inkl. WebSocket-Broadcast (Event-Typ pipeline_step). 9 Hooks im Orchestrator
  speisen die Anzeige.
- API: GET /api/incidents/{id}/pipeline liefert Definition + letzten Stand
  (Zahlen aus letztem Refresh, Multi-Pass-Konsolidierung).
- Frontend: pipeline.js rendert Vollbild-Blockkette mit pulsierendem Glow am
  aktiven Block, animierten Pfeilen bei Datenfluss, Haekchen am fertigen Block.
  Hover-Tooltip mit Erklaerung in Nutzersprache, Klick oeffnet Detail-Popup.
  Bei Research-Lagen leuchtet ein Schleifen-Pfeil pro Mehrfach-Durchlauf auf.
  Mini-Variante (nur Icons) im Refresh-Progress-Popup.
- CSS: Light/Dark-Theme-fest, dezenter Circuit-Hintergrund (5% Opacity),
  Mobile-vertikale Stapelung unter 900px, prefers-reduced-motion respektiert.
- Uebersprungene Schritte (z.B. Geoparsing ohne neue Artikel) werden
  ausgeblendet, brandneue Lagen ohne Refresh zeigen Hinweis.

Tooltips bewusst in normaler Sprache ohne Internas (keine Modellnamen,
keine Toolnamen, keine Phasen-Labels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:53:44 +02:00
2b51e49d0d Release-Notes: Hintergrundbild-Unschärfe zuverlässiger und vollständiger 2026-05-01 01:12:45 +02:00
Claude Code
e3fe7fac85 fix(blur): Refresh-Blur stabilisieren und Header mit-blurren
Problem: Beim Anlegen einer neuen Lage verschwand der Blur-Effekt auf dem
Hintergrund-Inhalt sobald das Browserfenster in der Groesse veraendert wurde.
Zudem blieb der Lagen-Titel im Header offen sichtbar, waehrend der Inhalt
darunter geblurrt war — der wechselnde Titel war also klar lesbar.

Ursache:
- Blur lag auf .tab-panels und parallel auf .tab-panel — zwei verschachtelte
  Composite-Layer, die bei jedem Reflow neu berechnet werden.
- transition: filter 0.4s ease auf .tab-panel — bei Resize lief die Transition
  oft rueckwaerts oder pausierte, was den Blur visuell verschwinden liess.
- .incident-header-strip lag ausserhalb von .tab-panels und war dadurch nie
  geblurrt (Titel/Aktionen/Beschreibung blieben offen sichtbar).

Aenderungen:
- Blur-Anker hochgezogen auf #incident-view (Klasse refresh-blurred), so dass
  Header und Tab-Panels gemeinsam unscharf werden.
- Nur noch eine Filter-Ebene (filter: blur(8px)).
- Transition entfernt — Blur soll schlagartig kommen und gehen, kein
  lesbarer Zwischenzustand beim Reflow.
- will-change: filter; transform: translateZ(0); — erzwingt einen persistenten
  GPU-Composite-Layer, der bei Window-Resize stabil bleibt.

Headless-Tests bestaetigen: alte Variante 89.8% Pixel-Stabilitaet ueber 6
Resize-Zyklen mit Content-Mutation, neue Variante 97.0% (Rest = Content-Diff).
2026-04-30 22:40:51 +00:00
Claude Code
88b18d0775 fix(researcher): Robusteres JSON-Parsing der Claude-Antworten
Behebt das Symptom, dass Recherche-Lagen wie staging Lage 6 "Friedrich Merz"
trotz erfolgreichem Refresh leer blieben. Claude lieferte nicht-leere Antworten
(1226-2125 Zeichen), die der bisherige Regex-Parser nicht extrahieren konnte —
die Recherche meldete "0 Artikel" und der Refresh wurde stumm als Erfolg
verbucht.

Aenderungen:
- _parse_response, select_relevant_feeds, extract_dynamic_keywords und
  select_relevant_telegram_channels nutzen jetzt json.JSONDecoder.raw_decode
  ueber Modul-Helper _extract_json_array/_extract_json_object. Damit werden
  auch JSON-Bloecke mit Vor-/Nachtext, Markdown-Fences oder verschachtelten
  Objekten zuverlaessig erkannt.
- Bei Parse-Fehlschlag wird jetzt ein gekuerztes Sample der Claude-Antwort
  geloggt, damit kuenftige Faelle direkt debuggbar sind.
- Neue ResearcherParseError-Exception unterscheidet "echt 0 Treffer" von
  "Antwort kaputt". search() gibt zusaetzlich ein parse_failed-Flag zurueck.
- Orchestrator-Multi-Pass: wenn alle 3 research-Durchlaeufe 0 neue Artikel
  ergeben UND mindestens einer am Parser scheiterte, wird der Refresh als
  Fehler markiert (statt als stiller Erfolg). Der WebSocket-refresh_error
  loest dann die sichtbare UI-Meldung aus.

Adhoc-Lagen sind unveraendert: dort fangen RSS und Telegram die kaputte
Claude-Antwort auf, dafuer ist nur die Diagnose im Log neu.
2026-04-30 20:45:41 +00:00
AegisSight Dev
682828ea58 Update-Meldungen folgen Theme, korrekte Umlaute
Banner und Was-ist-neu-Modal nutzen jetzt CSS-Variablen
(--bg-card, --text-primary, --accent etc.) statt fester
Dark-Mode-Farben, damit sie sich automatisch dem Hell-/Dunkelmodus
anpassen. RELEASES.json: alte ae/oe/ue-Schreibweisen auf echte
Umlaute umgestellt + neuer Eintrag fuer diesen Fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:19:02 +00:00
ac5160010d Release-Notes: Blur versucht zu fixen 2026-04-29 22:10:55 +02:00
Claude Code
059395393c Fix: rAF-Sicherheitsnetz fuer Blur zentral in _showPopupProgress
Vorheriger Fix in selectIncident griff nicht beim handleRefresh-Pfad
(manueller Aktualisieren-Klick), weil dieser direkt UI.showProgress aufruft
ohne selectIncident zu durchlaufen. Damit blieb eine Lage, deren erster
Refresh per Klick angestossen wurde, unblurred.

rAF mit add("blurred") jetzt direkt in _showPopupProgress (components.js),
sobald state.isFirst gesetzt ist. Damit greift der Blur in jedem Pfad, der
durch _showPopupProgress laeuft — selectIncident, handleRefresh,
handleStatusUpdate (WebSocket), Initial-Restore.

Der zentrale rAF in selectIncident ist redundant und wieder entfernt.
Der _willReBlur-Skip von remove("blurred") in selectIncident bleibt
erhalten — verhindert ueberfluessiges remove+add im selben Tick.

cache-bust components.js auf v=20260427a, app.js auf v=20260427c.
2026-04-27 20:52:39 +00:00
Claude Code
14d1062583 Fix: Blur greift bei jedem ersten Durchlauf der ausgewaehlten Lage
Vorheriger Patch hatte den rAF nur im Create-Flow gesetzt. Damit funktionierte
zwar das Anlegen, aber das Auswaehlen einer existierenden Lage, deren erste
Recherche gerade laeuft (oder nach einem manuellen ersten Refresh), blieb
unblurred.

Loesung: rAF mit add("blurred") jetzt zentral in selectIncident, sobald der
Progress-State isFirst=true und nicht minimiert ist. Damit greift der Blur
in allen Pfaden (Anlegen, manueller Refresh, Auswahl einer laufenden Lage,
Wechsel zwischen Faellen, Initial-Load via savedId).

Der zusaetzliche rAF in createIncident von 2ee90a4 ist damit redundant und
wieder entfernt — der zentrale Hook in selectIncident deckt den Fall mit ab.

cache-bust app.js auf v=20260427b.
2026-04-27 19:43:18 +00:00
Claude Code
2ee90a4b3b Fix: Blur greift sofort beim Anlegen einer neuen Lage
Im Create-Flow wurde .blurred in selectIncident() erst entfernt und gleich
darauf via _showPopupProgress wieder gesetzt. CSS filter:blur greift in der
Kombination (frischer isFirst-State + selectIncident im selben Tick + viel
vorausgehende DOM-Manipulation durch Modal-Close + renderSidebar) nicht
zuverlaessig im selben Frame — der Fall war bis zum Wegklicken/Zurueckklicken
unblurred.

selectIncident: remove("blurred") wird uebersprungen, wenn der zustaendige
Progress-State isFirst=true und nicht minimiert ist (Blur soll bestehen
bleiben statt remove+add im selben Tick).

createIncident: zusaetzlich requestAnimationFrame mit grid.classList.add im
naechsten Frame, falls der Browser den ersten add-Aufruf in selectIncident
nicht visuell uebernommen hat. Doppeltes Setzen ist idempotent.

dashboard.html: cache-bust app.js auf v=20260427a.
2026-04-27 19:29:00 +00:00
d1f88c9e9f Release-Notes: Update-Modal beim ersten Besuch 2026-04-26 23:08:07 +02:00
Claude Code
ad53786a24 Update-System: Modal auch beim ersten Besuch zeigen
Frueher wurde beim allerersten Treffen mit dem Update-System der lastSeen-
Marker stillschweigend gesetzt, ohne Modal — User sah erst beim ZWEITEN
Update was. Fuer Kunden-Onboarding ist das suboptimal: sie sollen direkt
sehen, dass das System existiert und welche Updates aktuell sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:43:24 +00:00
a9806a586b Release-Notes: Updatenachricht bei Deployment 2026-04-26 22:40:34 +02:00
Claude Code
2aaa51e2a8 Update-System Frontend: Banner + Was-ist-neu-Modal
Beim ersten Login nach einem Update zeigt der Monitor nun ein Modal mit den
Release-Notes des Updates (aus RELEASES.json). Wenn waehrend einer laufenden
Sitzung ein neues Update live geht, erscheint unten rechts ein Banner mit
einem Aktualisieren-Knopf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:32:50 +00:00
Claude Code
2df37cb617 Update-System: /api/version + /api/release-notes + RELEASES.json
Frontend kann jetzt erkennen, wann eine neue Version live ist, und dem Nutzer
einen passenden Hinweis sowie die Release-Notes anzeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:28:10 +00:00
Claude Code
5473ba3ed7 WICHTIG: DB_PATH per ENV ueberschreibbar; data-Symlink aus Repo entfernt
Verhindert dass Staging und Live versehentlich dieselbe DB nutzen
(Symlink data wurde frueher beim git clone mitgeklont und zeigte
auf das gleiche physische Verzeichnis /home/claude-dev/osint-data).
Staging muss jetzt DB_PATH in der .env explizit setzen.
2026-04-26 19:42:33 +00:00
Claude Code
8042639d20 CLAUDE.md: Auto-Deploy + Promote-UI + Live-systemd dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:14:35 +00:00
claude-dev
ec53ab27cd CLAUDE.md: Staging-Umgebung dokumentiert (Service, DB, .env, Workflow)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:56:29 +00:00
claude-dev
c73541cdbe Block C: Prompt-Umlaute korrigiert + Timeout parametrisiert
- ENHANCE_PROMPT_ADHOC und ENHANCE_PROMPT_RESEARCH: Umschreibungen durch
  echte Umlaute ersetzt (fuer -> fuer, praezises -> praezises, ...). Behebt
  den Widerspruch, dass der Prompt "echte Umlaute verwenden" forderte,
  die Anweisung selbst aber ae/oe/ue/ss nutzte.
- call_claude() bekommt neuen timeout-Parameter. None = Fallback auf
  CLAUDE_TIMEOUT (1800s), sonst Override in Sekunden. asyncio.wait_for
  und die cancel-aware Variante nutzen durchgaengig den effective_timeout.
- Enhance-Endpoint ruft call_claude mit timeout=60 auf (Haiku-Single-Shot,
  vorher global 1800s).
- chat.py _call_claude_chat: Timeout von 60s auf 120s erhoeht (Chat-Antworten
  koennen etwas laenger dauern, haben aber keinen Anspruch auf 30 Min).
2026-04-23 17:56:28 +00:00
claude-dev
5d5ec7c924 Block B: ClaudeCliError + differenzierte HTTP-Status + Rate-Limit-Retry
- Neue Exception-Klasse ClaudeCliError(error_type, message) in claude_client.py
  mit Kategorien rate_limit / auth_error / timeout / cli_error.
- _classify_cli_error() als geteilter Klassifikator (Keywords fuer Rate-Limit
  und Auth-Fehler wie "does not have access", "login again").
- call_claude() erkennt jetzt auch is_error=true im JSON bei returncode=0
  (Hauptursache des Ausfalls vom 22.04.: CLI liefert "Your organization
  does not have access" mit is_error=true statt Exit-Code).
- Orchestrator: ClaudeCliError mit rate_limit/timeout als transient behandelt
  (3 Retries mit Backoff 0s/120s/300s). auth_error/cli_error brechen sofort
  ab ohne Retry. Behebt den bestehenden Bug, dass Rate-Limit-Fehler gar nicht
  retried wurden.
- routers/incidents.py Enhance-Endpoint: ClaudeCliError wird auf
  503 (auth_error) / 429 (rate_limit) gemappt, TimeoutError auf 504.
- routers/chat.py _call_claude_chat(): wirft jetzt ClaudeCliError statt
  generischem RuntimeError. Chat-Endpoint mappt auth_error auf 503.
- Frontend: neue ApiError-Klasse in api.js mit status+detail.
  generateDescription() in app.js zeigt differenzierte Toasts nach
  HTTP-Status (503/429/504/403).
- dashboard.html: Cache-Bust api.js + app.js auf v=20260423a
2026-04-23 17:54:13 +00:00
claude-dev
e8ac0d0c50 Block A: License-Check + Credits-Tracking fuer Enhance und Chat
- Neuer Helper charge_usage_to_tenant() in services/license_service.py:
  UPSERT in token_usage_monthly und Credits-Abzug aus licenses.credits_used.
  Wiederverwendbar fuer alle Claude-Call-Verursacher.
- Orchestrator: Inline-Buchungslogik (35 Zeilen) durch Helper-Aufruf ersetzt.
- routers/incidents.py POST /enhance-description: require_writable_license
  statt get_current_user, db_dependency hinzugefuegt, Credits-Buchung mit
  source="enhance" nach jedem Claude-Call.
- routers/chat.py POST /: analog require_writable_license + Credits-Buchung
  mit source="chat". _call_claude_chat() gibt jetzt zusaetzlich ClaudeUsage
  zurueck.

Abgelaufene/gesperrte Lizenzen koennen damit keine Haiku-Calls mehr ausloesen,
und alle Kosten werden konsistent auf Tenant-Ebene verbucht.
2026-04-23 17:49:32 +00:00
claude-dev
c8a8e10020 Chat-Doku aktualisiert + Tutorial-Einstieg temporaer deaktiviert
- Chat-System-Prompt: Aktualisierungs-Modi (Minuten/Stunden/Tage/Wochen, 10-Min-Minimum, Startzeit), 5 Faktencheck-Status (Bestaetigt, Gesichert, Unbestaetigt, Umstritten, Widerlegt), Export mit PDF/DOCX und Bereichsauswahl
- Tutorial-Button in Sidebar auskommentiert (Ueberarbeitung)
- Tutorial-Trigger im Chat auskommentiert (Opener-Hinweis und Keyword-Erkennung)
2026-04-23 17:43:27 +00:00
claude-dev
a579e2c275 Neueste Entwicklungen aus Lagebild statt aus Artikel-Strom
Bisher extrahierte der Generator Bullets direkt aus den neu eingesammelten
Artikeln und mergte sie mit den bestehenden Developments. Das fuehrte zu
zwei wiederkehrenden Problemen:

1. Off-topic Artikel, die den Keyword-Prefilter aber nicht den Topic-Filter
   passiert hatten, konnten als Bullet landen (die Kachel bildete dann
   Nebenschauplaetze des Weltgeschehens ab statt der Lage).
2. Alte Bullets blieben stehen, auch wenn sie laengst nicht mehr die
   'neuesten' Entwicklungen waren — nur sehr ueberholte Eintraege fielen
   durch das 8-Bullet-Cap raus.

Neue Logik: Der Generator nimmt das frisch erzeugte Lagebild als autoritative
inhaltliche Grundlage und waehlt daraus Bullets aus, die durch eine aktuelle
belegende Meldung (<~7 Tage) gestuetzt sind. Dadurch:

- Thematisch sauber: Lagebild enthaelt bereits nur relevante Inhalte.
- Echt 'neueste': Alte Hintergrund-Erwaehnungen im Lagebild fallen raus,
  weil kein aktueller Artikel sie belegt.
- Klar datiert: Zeitstempel zwingend aus article.published_at der
  belegenden Meldung.
- Kompakt: 4-6 Bullets (vorher 8), nach Zeitstempel absteigend.

Kein Merge mit previous_developments mehr — bei jedem Refresh neu generiert
(behebt das Drift-Problem). previous_developments bleibt nur als Fallback,
falls der Generator im Einzelfall 0 Bullets parst.
2026-04-21 14:23:18 +00:00
claude-dev
efae707fa9 Fix: Blur + Aktions-Lock beim Anlegen eines Falls sofort aktiv
Beim Create-Flow wurde selectIncident() aufgerufen, BEVOR der Fall
als refreshend markiert wurde. Dadurch entfernte selectIncident den
'.blurred'-Zustand des Tab-Containers und rief _lockActionsIfFirst(false)
auf — der Fallinhalt war zwischen Oeffnen und Eintreffen der ersten
WebSocket-Statusnachricht kurzzeitig klickbar und unblurred.

Jetzt wird der Refresh-Status und ein Initial-State mit isFirst=true
schon VOR selectIncident gesetzt. selectIncident erkennt isRefreshing
und ruft _showPopupProgress + _lockActionsIfFirst(true) mit dem
bestehenden State auf — Blur und Lock greifen sofort.
2026-04-21 14:02:52 +00:00
claude-dev
05b60ffb35 Fix: Timer springt beim Seiten-Reload nicht mehr zurueck
Bei Research-Multi-Pass (3 Durchlaeufe) und bei Retry-Versuchen wird
pro Pass/Retry ein neuer refresh_log-Eintrag mit frischem started_at
angelegt. /incidents/refreshing gab dadurch beim Reload den spaeteren
started_at zurueck statt des urspruenglichen Session-Starts — der
Frontend-Timer sprang auf 0:00 zurueck.

Orchestrator traegt jetzt _current_task_started_at in-memory, gesetzt
beim Queue-Pickup und geraeumt im finally. /incidents/refreshing liefert
diesen Session-Start fuer den aktuell laufenden Task (Fallback: letzter
refresh_log-Eintrag, falls der Server zwischenzeitlich neu gestartet
wurde).
2026-04-21 13:42:51 +00:00
claude-dev
60b8646fe4 Semantischer Topic-Filter gegen off-topic Keyword-Zufallstreffer
Neue Artikel passieren jetzt vor DB-Speicherung einen Haiku-Relevanzfilter
(AnalyzerAgent.filter_relevant_articles), der Artikel verwirft, die nur
auf generische Keywords matchen, aber das Kernthema der Lage nicht
inhaltlich behandeln. Bei Parsing-/API-Fehler oder 100%-Rejection: Fallback
auf unveraenderte Kandidatenliste.

Orchestrator trennt DB-Dedup und INSERT, damit der Filter nur auf neue
Kandidaten laeuft (Kostenoptimierung). LATEST_DEVELOPMENTS-Prompt erhaelt
zusaetzliche Relevanz-Gate-Regel als zweite Sicherung.

Hintergrund: Incident 'Russische Militaerblogger' sammelte bisher Iran-,
Nahost- und allgemeine Ukraine-Artikel ein, weil Keyword-Match ab 2 von 8
Begriffen ('iran', 'russland', 'drohne', ...) genuegt. Der semantische
Filter verwirft solche Zufallstreffer.
2026-04-21 12:01:56 +00:00
claude-dev
285df86c7b Export-Metadaten: Umlaut-Fix, xmpMM:VersionID + History
- dc:rights und xmpRights:UsageTerms: Empfaenger -> Empfänger (echte Umlaute)
- Scope-Labels: Vollstaendiger Bericht -> Vollständiger Bericht (zwei Stellen)
- DOCX-Fallback-Text: verfuegbar -> verfügbar
- xmpMM:VersionID: Snapshot-Count der Lage (Proxy fuer Berichts-Revision).
  Router laedt COUNT(*) FROM incident_snapshots und reicht es durch.
- xmpMM:History: Audit-Event pro Export als rdf:Seq-Eintrag mit Timestamp,
  softwareAgent, InstanceID, Scope und Version. Single-Event-Format aus
  pragmatischem Grund (pikepdf-API unterstuetzt keine nativen stEvt-
  Strukturen; Raw-XML-Injection waere dafuer noetig).
2026-04-20 19:33:18 +00:00
claude-dev
5add8d9d59 Export-Metadaten: Dublin Core, xmpRights und xmpMM nachruesten
Zusaetzliche XMP-Felder im PDF:
- dc:publisher (Organisation, Fallback AegisSight)
- dc:identifier (urn:aegissight:incident:<id>:<timestamp>)
- dc:date (Dokumentendatum, ergaenzend zu xmp:CreateDate)
- dc:format (application/pdf)
- dc:type (Report)
- dc:rights (Vertraulichkeitshinweis)
- pdf:Producer im XMP gespiegelt
- xmpRights:Marked (True) und xmpRights:UsageTerms (= dc:rights)
- xmpMM:DocumentID + xmpMM:InstanceID (UUIDs, frisch pro Export)

Damit koennen DMS-Systeme die Berichte versionieren, eindeutig
identifizieren und Vertraulichkeitshinweise anzeigen.
2026-04-20 19:23:54 +00:00
claude-dev
949df868ff Export: XMP-Metadatenblock und CreationDate/ModDate via pikepdf nachziehen
WeasyPrint 68.1 schreibt weder XMP noch Create-/ModDate ins PDF. Das Post-
Processing via pikepdf ergaenzt beide:

- Info-Dict: /CreationDate + /ModDate im PDF-Standardformat
  (D:YYYYMMDDHHmmSS+HHmm) aus Incident.created_at / updated_at
- XMP-Block mit Dublin Core (dc:title, dc:creator, dc:description,
  dc:subject, dc:language), PDF (pdf:Keywords) und XMP (CreatorTool,
  CreateDate, ModifyDate, MetadataDate) Namespaces

Damit werden die Exporte sowohl von klassischen Tools (Explorer, Finder)
als auch von DMS-Systemen (SharePoint, Bridge, Acrobat) vollstaendig
indexiert. Fallback: Bei Fehler im Post-Processing wird das Original-PDF
zurueckgegeben, Export schlaegt nie fehl.
2026-04-20 19:15:14 +00:00
claude-dev
9293e66d01 Export-Metadaten: category_labels JSON-robust parsen, Keyword-Sanitizer
- category_labels ist in der DB ein JSON-Dict (primary/secondary/tertiary/
  mentioned), nicht ein Komma-String. Der bisherige split(",") fuehrte dazu,
  dass ein nacktes { als Keyword durchrutschte. WeasyPrint bricht den
  PDF-Keywords-Stream an dieser Stelle ab, weil { in PDF-Syntax eine
  Sonderbedeutung hat — Ergebnis war "OSINT, Live-Monitoring, AegisSight, {".
- Neuer Parser: erst JSON (Dict oder Liste), Fallback auf Komma-String.
- _sanitize_keyword(): filtert {, }, [, ], Backslash und normalisiert
  Whitespace in allen Keywords (Defense in Depth).
2026-04-20 19:09:38 +00:00
claude-dev
c0f68e40a5 Export: PDF/DOCX-Dateimetadaten (Title, Author, Subject, Keywords, Category, Comments)
- Neue Helper-Funktion _build_export_metadata baut einheitliches Metadaten-Dict
- PDF via HTML-Meta-Tags (title, author, description, keywords, generator, lang)
- DOCX via doc.core_properties (title, author, subject, keywords, comments,
  category, last_modified_by, language, content_status, created, modified)
- Keywords aus OSINT + Typ + Organisation + category_labels + Top-5-Orten
- Comments-Feld mit strukturiertem Block (Incident-ID, Typ, Scope, Umfang, Orte)
- Router laedt Organisation + Top-Orte aus article_locations und reicht sie durch
2026-04-20 18:58:34 +00:00
0d6ad8ea90 Incident-Response: sources_json nur noch via Lazy-Endpunkt, Sidebar schlank
Backend:
- IncidentResponse: sources_json-Feld entfernt (Detail-GET liefert es
  nicht mehr mit).
- Neues Schema IncidentListItem fuer GET /incidents (Sidebar):
  Ohne summary, ohne sources_json. Ein has_summary-Bit fuer
  Erster-Refresh-Erkennung, description bleibt fuer das Edit-Modal.
- list_incidents selektiert nur die noetigen Spalten (kein SELECT *)
  — spart bei grossen Lagen Speicher + Serialisierung.
- Neuer Endpunkt GET /incidents/{id}/sources liefert geparstes
  Sources-Array fuer Zitate-Lookups (Lazy).

Frontend:
- api.js: getIncidentSources(id).
- app.js: loadIncidentDetail laedt /sources parallel, speichert Array
  in _currentSources. Alle renderSummary/Zusammenfassung/
  LatestDevelopments-Aufrufe bekommen jetzt _currentSources statt
  incident.sources_json. inc.summary-Checks -> inc.has_summary.
- components.js: _parseSources(input) akzeptiert Array ODER String
  (Rueckwaertskompatibilitaet). renderZusammenfassung, renderSummary,
  renderLatestDevelopments nutzen den Helper.

Hintergrund: Die Sidebar-Liste lieferte bei 17 Lagen 1,23 MB
(Iran allein 386 KB wegen sources_json + summary). Detail-Endpunkt
lieferte sources_json (324 KB bei Iran) bei jedem Oeffnen mit.
Beides jetzt radikal kleiner — die 324 KB Sources gibt's nur
einmalig auf Anfrage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:07:46 +02:00
a302790777 Locations: Aggregation in SQL (GROUP BY + Window)
Ersetzt den rohen JOIN ueber article_locations x articles (bei Iran
21.814 Zeilen, 11 MB Payload) durch drei kleine aggregierte Queries:
  1. Orte per GROUP BY (name, lat, lon) — direkt die Ergebnismenge.
  2. Kategorien pro Ort per GROUP BY fuer die dominante Kategorie.
  3. Sample-Artikel (max. 10 pro Ort) via ROW_NUMBER() OVER PARTITION BY.

Response-Shape unveraendert ({category_labels, locations: [...]}), keine
Frontend-Aenderung noetig. Priorisierung primary > secondary > tertiary >
mentioned bleibt erhalten.

Erwarteter Effekt: Iran-Locations 11 MB -> <500 KB; Query-Zeit sinkt
zusaetzlich, da kein 21k-Zeilen-JOIN mehr materialisiert werden muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:47:50 +02:00
9a43dffa6c Articles: Paginierung, Timeline-Buckets, Sources-Summary-Endpunkt
Backend:
- GET /{id}/articles paginiert jetzt per limit/offset (Default 500,
  Max 1000) und unterstuetzt optionalen search-Parameter (LIKE ueber
  headline/source/content). Response-Shape: {total, articles}.
- Neuer Endpunkt GET /{id}/articles/sources-summary liefert pro Quelle
  {source, article_count, languages} sowie language_counts gesamt —
  serverseitige Aggregation, unabhaengig von Artikel-Paginierung.
- Neuer Endpunkt GET /{id}/articles/timeline-buckets?granularity=hour|day|week|month
  aggregiert Artikel + Snapshot-Counts pro Zeitbucket (fuer spaetere
  Timeline-Zaehler ueber die volle Historie).
- database.py: Index idx_articles_incident_collected auf
  (incident_id, collected_at DESC) fuer schnelleres ORDER BY + Pagination.

Frontend:
- api.js: getArticles({limit, offset, search}),
  getArticlesSourcesSummary(), getArticlesTimelineBuckets().
- app.js: loadIncidentDetail laedt erste Seite (500 Artikel), startet
  _loadSourcesSummary parallel und zieht restliche Artikel
  batchweise (500er Bloecke) im Hintergrund nach, bis _currentArticlesTotal
  erreicht ist. rerenderTimeline nach jedem Batch.
- components.js: renderSourceOverviewFromSummary(data) rendert aus
  Aggregat-Daten (ersetzt clientseitige Zaehlung ueber geladene Artikel).

Hintergrund: /articles lieferte bei der Iran-Lage 22 MB (17.286 Artikel
mit SELECT *). Die Erstantwort sinkt auf ~650 KB (500 Artikel), weitere
werden progressiv im Hintergrund nachgeladen. Quellenuebersicht zeigt
dank Aggregat-Endpunkt sofort alle Quellen + Sprachen komplett.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:46:40 +02:00
194790899c Snapshots: Liste ohne Volltext, Lazy-Load + serverseitige Suche
Backend:
- GET /{id}/snapshots liefert nur noch schlanke Shape (Metadaten +
  SUBSTR(summary,1,300) AS summary_preview), kein Volltext, kein sources_json.
- Neuer Endpunkt GET /{id}/snapshots/{snapshot_id} fuer Volltext-Lazy-Load.
- Neuer Endpunkt GET /{id}/snapshots/search?q=... fuer serverseitige
  Volltextsuche ueber alle Snapshots einer Lage.

Frontend:
- api.js: getSnapshot() und searchSnapshots() ergaenzt.
- app.js: _snapshotFullCache, Volltext wird beim Aufklappen eines
  Snapshot-Eintrags per lazyLoadSnapshotDetail() nachgeladen und gecacht.
- Suche ueber Snapshots filtert weiterhin clientseitig ueber summary_preview.

Hintergrund: Bei grossen Lagen (Iran-Lage: 347 Snapshots) fiel die
Snapshots-Listenantwort mit Volltext-Summaries auf ~54 MB. Die Liste
faellt damit auf ~150 KB; Volltexte werden nur on-demand geladen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:42:08 +02:00
claude-dev
34be98edaf Latest-Developments: Bullet-Format Name|URL statt nur Name
Problem: Pill-Link verwies auf falschen Post, weil sources_json fuer
Telegram-Kanaele viele Eintraege mit gleichem Namen aber unterschiedlichen
Post-URLs hat. Der Name-Match traf den ersten Eintrag (falschen Post).

Fix: Bullet-Format von {Name, Name} auf {Name|URL, Name|URL} erweitert.
Backend-Parser loest {M<ID>} nun zu Name|URL auf, URL kommt direkt vom
articles.source_url des belegenden Artikels. Kein sources_json-Lookup
noetig, keine Name-Kollision mehr moeglich.

Backend (analyzer.py):
- _parse_latest_developments: articles_by_id speichert (name, url) Tuple,
  Items werden als Name|URL gespeichert. Uebernommene Klammer-Items mit
  Pipe werden akzeptiert. Legacy-Items ohne Pipe bleiben als reiner Name.
- Prompt-Regel und Output-Beispiel auf {Name|URL, Name|URL} erweitert.

Frontend (components.js):
- buildPill-Aufruf vor Pipe-Split: Name und URL getrennt, wenn URL vorhanden
  wird Pseudo-src {name, url} uebergeben — eindeutiger Klicklink. Ohne URL
  Fallback auf lookupByName in sources_json (fuer Legacy-Bullets).
2026-04-18 23:19:02 +00:00
claude-dev
82e46792c7 Source-Pill: Clip entfernt, langer Kanal-Pfad vollstaendig sichtbar
max-width/overflow-hidden/text-overflow-ellipsis aus .dev-source-pill raus.
Stattdessen white-space: normal + overflow-wrap: anywhere — Pill waechst
mit Inhalt, Zeile kann umbrechen. Beispiel Telegram-Kanal iranmilitarymag
war vorher bei (t.me/iranmilitaryma... abgeschnitten.
2026-04-18 22:57:54 +00:00
claude-dev
e495fa8e61 Telegram-Pill: Kanal-Pfad statt generisches Label
Statt allgemeinem (Telegram-Link) wird jetzt der tatsaechliche Kanal-Pfad
angezeigt, z.B. (t.me/iranmilitarymag) — extrahiert aus der Source-URL
per Regex. Damit ist der Kanal auf einen Blick erkennbar, auch wenn der
Quellenname in nichtlateinischer Schrift vorliegt.
2026-04-18 22:53:09 +00:00
claude-dev
e15ed0c21e Dashboard: GridStack durch Tab-Navigation ersetzen
Der Monitor-Dashboard zeigte bisher alle sechs Kacheln gleichzeitig in
einem GridStack-Layout (Drag/Resize, je Kachel eigenes Scrolling). Nutzer-
wunsch: Analog zur Lagebild-Seite nur ein Tab-Panel gleichzeitig, maximiert
auf volle Breite, Seiten-Scroll statt interne Scrollbars.

Aenderungen:
- dashboard.html: Layout-Toolbar + grid-stack-Wrapper entfernt; neue tab-nav
  mit 6 Buttons + tab-panels mit 6 Panels. GridStack CDN-Links raus.
- layout.js: GridStack-Init/toggleTile/reset komplett entfernt. Neu:
  switchTab(tabId) + restoreTabFor(incidentId) mit localStorage-Persistenz
  pro Lage osint_tab_id. applyTypeLabels fuer adhoc vs. research. Legacy-
  Methoden sind No-Op-Stubs.
- app.js: renderIncidentDetail ruft LayoutManager.restoreTabFor und
  applyTypeLabels auf. openContentModal-Trigger aus Card-Titeln raus.
  Tile-Resize-Bloecke fuer Quellen und Timeline entfernt.
- components.js: Telegram-Pills bekommen Suffix Telegram-Link, wenn die
  URL auf t.me verweist.
- style.css: grid-stack/layout-toggle Klassen raus; neue tab-nav/tab-btn/
  tab-panel Klassen. Internes Scrolling entfernt. map-container 600px.

Alte osint_layout-Eintraege werden ignoriert.
2026-04-18 22:34:36 +00:00
claude-dev
3b9e9e25c2 public_api: latest_developments in Incident-Response aufnehmen
Die oeffentliche API (/api/public/lagebild) liefert jetzt latest_developments
als Feld im Incident-Objekt. Damit kann der Website-Sync das Feld in
current.json und summary.json uebertragen, und die Lagebild-Seite kann einen
Tab Neueste Entwicklungen rendern.
2026-04-18 22:05:45 +00:00
claude-dev
f05bd1a064 QC: Umlaut-Dict aus hunspell-de-de generieren (statt handkuratiert)
Loest das Abdeckungs-Problem des handkuratierten Dicts (~300 Eintraege,
~95%). Neu: vollautomatisch erzeugtes Korpus-Dict aus hunspell-de-de
mit 153.869 Eintraegen (>99% Abdeckung), plus schlankes Supplement
fuer Komposita, die hunspell nicht liefert.

Build-Skript (scripts/build_umlaut_dict.py):
- ruft /usr/bin/unmunch gegen /usr/share/hunspell/de_DE.dic+aff auf
- filtert Woerter mit echten Umlauten (ä/ö/ü/ß)
- generiert je Wort die Umschreibungsform (ae/oe/ue/ss) + Capitalize
- Mehrdeutigkeits-Check: skippt Paare wo die Umschreibung selbst
  ein gueltiges deutsches Wort ist (z. B. dass/daß, Masse/Maße, Busse/Buße)
- Ergebnis: 153.869 Eintraege, 27 mehrdeutige Formen ausgefiltert
- Alphabetisch sortiertes JSON (diff-freundlich)

Laufzeit-Refactor (src/services/post_refresh_qc.py):
- _UMLAUT_BASE Dict (handkuratiert) entfernt, dafuer JSON-Loader
  beim Modul-Import aus src/services/umlaut_dict.json
- _MANUAL_SUPPLEMENT fuer Luecken (Konjunktiv saeen, Amtstitel-
  Komposita wie Aussenminister/Parlamentspraesident, Strassen-
  Komposita, Fuehrungs-Komposita) — ueberlagert Korpus-Dict
- _UMLAUT_WHITELIST erweitert um englische Fremdwoerter (Boeing,
  Business, Access, Process, Message, Password, Miss, Boss, Goethe,
  Yahoo, Israel, Israels)
- Regex-Strategie umgestellt: statt riesigem alternierenden Pattern
  ueber alle Keys jetzt Tokenizer (_WORD_PATTERN) + O(1) Dict-Lookup
  pro Wort. Deutlich performanter bei 150k+ Eintraegen.
- normalize_german_umlauts() Signatur unveraendert
- normalize_umlaut_fields() unveraendert
- Einhaengung in run_post_refresh_qc() unveraendert

Daten-Artefakt (src/services/umlaut_dict.json):
- 4.88 MB alphabetisch sortiertes JSON
- Im Repo committet zwecks Reproduzierbarkeit und kein hunspell-
  Laufzeit-Abhaengigkeit im Container

Verwerfbarkeit voll erhalten:
- git revert entfernt alle drei neuen Elemente
- Bestand in DB bleibt repariert (korrektes Deutsch, kein Schaden)
- hunspell-Paket kann bleiben oder mit apt purge entfernt werden

Bootstrap-Rerun mit neuem Dict:
- 7 Lagen aktualisiert, 306 zusaetzliche Ersetzungen
- Lage #6 (Irankonflikt) von 140 ursprungs- und 15 Rest-Treffern
  nach voriger Runde jetzt auf 0 Hard-Hits
- andere aktive Lagen insgesamt 8 verbleibende Rest-Treffer
  (spezielle Eigennamen, koennen bei Bedarf ins Supplement)

Performance:
- Dict-Load beim Modul-Import: ~100 ms
- Gesamt Unit-Tests (11 Faelle): 161 ms
- Refresh-Pfad unveraendert schnell: O(Wortzahl) mit Hashmap-Lookup
2026-04-18 21:17:46 +00:00
claude-dev
8a888a17a5 Live-Monitoring: Parser toleranter (Dash optional, Datum ohne zweiten Punkt) + Backfill-Script
Claude Haiku 4.5 laesst gelegentlich den fuehrenden Dash oder den zweiten
Datums-Punkt im Bullet-Format weg (z.B. "[18.04 21:49]" statt
"- [18.04. 21:49]"). Der strikte Parser-Regex verwarf dadurch alle Bullets.

- Regex akzeptiert nun Dash als optional und zweiten Datums-Punkt als optional
- Parser normalisiert Datum + Zeit auf kanonisches Format "DD.MM. HH:MM" mit Zero-Padding
- Frontend-Regex analog toleranter (auch fuer Altdaten-Mix)
- OUTPUT-FORMAT-Hinweis im Prompt verschaerft ("JEDE Zeile beginnt mit - ")

Backfill-Skript (scripts/backfill_latest_developments.py): Laedt die N
neuesten Artikel einer Lage aus der DB und ruft generate_latest_developments
mit previous_developments=None auf — nuetzlich nach DB-Cleanups, wenn die
inkrementelle Logik zu wenige Bullets liefert.

Einmaliger Run fuer Lage #66 (Militaerblogger): 8 Bullets vom 18.04. mit
aufgeloesten Quellen (Spiegel, Guardian, Bloomberg, n-tv, Telegram-Kanaele).
2026-04-18 21:14:44 +00:00
claude-dev
89ab158202 Live-Monitoring: Quellen-IDs deterministisch aufloesen, Bias-Markierung raus
Aenderung am Grund-Mechanismus: LLM liefert pro Bullet die Meldungs-IDs
im Format {M<ID>, M<ID>}, das Backend loest die IDs gegen new_articles
zu Quellen-Namen auf und schreibt {Reuters, Rybar} in die DB. Uebernommene
Bullets aus previous_developments behalten ihre bestehende {Name}-Klammer.

Bullets ohne Quellen-Klammer oder mit unaufloesbarer Klammer werden vom
Parser verworfen — dadurch existiert "Keine Quelle" nicht mehr.

Frontend: Bias-Farbcodierung (pro-RU, staatsnah) + zugehoerige Heuristik
_classifyBias/_biasLabel entfernt. Kein Sonderfall-Rendering fuer leere
Pills mehr.
2026-04-18 20:50:46 +00:00
claude-dev
5c95d85871 Live-Monitoring: Quellen-Namen pro Bullet (Prompt + Frontend-Parser)
Der LATEST_DEVELOPMENTS-Prompt produzierte Bullets ohne Citations — das
Frontend zeigte daher "Keine Quelle". Prompt ergaenzt: jedes Bullet endet mit
{Quellenname1, Quellenname2} (geschweifte Klammern, exakte Schreibweise aus
Quelle:-Zeile). Frontend-Parser extrahiert diese Klammer, matcht Namen
case-insensitive gegen sources_json und erstellt klickbare Pills.

Fallback fuer Legacy-Bullets: Inline-[N]-Citations werden weiterhin erkannt.
Altbestand-Bullets ohne Marker erhalten beim naechsten Refresh Quellen.
2026-04-18 20:27:16 +00:00
claude-dev
2ae8b9a341 Live-Monitoring: Neueste Entwicklungen als Karten mit Quellen-Pills
Der Bullet-Render fuer Live-Monitoring (adhoc) zeigt nun pro Eintrag eine
Karte mit klickbaren Quellen-Pills (Quellname statt nur [N]) im Header und
dezentem Zeitstempel rechts oben. Der Ereignistext steht darunter ohne
Inline-Citations. Bias-Markierung (pro-RU, staatsnah) als kleines Suffix.

Recherchen behalten den bisherigen renderZusammenfassung-Render unveraendert.
2026-04-18 19:53:21 +00:00
claude-dev
15a650bfc9 QC: Umlaut-Normalisierung + Prompt-Ergaenzung
Drei unabhaengige Schutzschichten gegen falsche Umschreibungen
(ae/oe/ue/ss statt ä/ö/ü/ß) im Lagebild:

1. Prompt-Ergaenzung in INCREMENTAL_ANALYSIS_PROMPT_TEMPLATE und
   INCREMENTAL_BRIEFING_PROMPT_TEMPLATE (analyzer.py): explizite
   Priorisierung, dass die Regel "echte UTF-8-Umlaute" Vorrang vor
   "bestehende Formulierungen beibehalten" hat. Adressiert den Fall,
   dass Claude beim inkrementellen Update Altlasten weitertraegt.

2. Deterministische Normalisierung in post_refresh_qc.py:
   - normalize_german_umlauts(text) - Regex mit Wortgrenzen, case-
     preserving, Whitelist-tauglich, ~140 Eintraege im Woerterbuch
     abgeleitet aus den 140 Hard-Hits in Lage #6
   - normalize_umlaut_fields(db, incident_id) - laedt summary und
     latest_developments, normalisiert, schreibt nur bei Aenderungen
     zurueck (idempotent)
   - Eingehaengt in run_post_refresh_qc() nach dem Location-Check,
     Fehler stoppen die Pipeline nicht (identisches Muster wie
     bestehende Checks)

3. scripts/bootstrap_umlaut_repair.py - Einmal-Skript zur
   Bestandsbereinigung der bereits gespeicherten summary-Felder.
   Idempotent. Beim initialen Lauf auf Produktiv-DB: 14 Lagen
   aktualisiert, 431 Ersetzungen insgesamt, Lage #6 von 140 auf
   15 Rest-Treffer reduziert.

Whitelist (leer): aktuell kein Konflikt zwischen deutschen Ziel-
Woertern und englischen Fremdwoertern. Kann bei Bedarf erweitert
werden ohne Schema-Aenderung.

Verifikation:
- py_compile OK fuer alle drei Dateien
- Service-Restart ohne Errors
- Unit-Tests: positive Faelle ("Oeffnung der Strasse" -> 4 Ersetzungen),
  Whitelist ("Boeing liefert Business-Access" -> 0 Ersetzungen),
  Komposita ("Wasserstrasse", "Parlamentspraesident") korrekt
- Bootstrap 2x ausgefuehrt (erster Lauf 288 Ersetzungen, zweiter 143
  nach Dict-Erweiterung), kumulativ 431

Architektur bleibt dormant ohne Daten-Altlasten: wenn keine Lage
Umschreibungen enthaelt, arbeitet normalize_umlaut_fields in <1ms
und schreibt nichts. Kein Overhead im Refresh-Pfad.
2026-04-18 14:00:00 +00:00
claude-dev
ed2ab1f3fc YouTube-Fallback aus Podcast-Kaskade entfernt
Der geplante YouTube-Captions-Fallback (Phase 2 via yt-dlp) wird nicht
umgesetzt. Begruendung: strategische Entscheidung, keinen YouTube-Scrape
als Quelle zu nutzen.

Geaendert:
- src/feeds/transcript_extractors/__init__.py:
  - try/except-Import fuer youtube-Modul entfernt (nie existiert)
  - Modul-Docstring aktualisiert (Stufen 1+2, kein 3)
  - source-Enum-Kommentar: nur noch rss_native / website_scrape

Konsequenz: Episoden, die weder Podcasting-2.0-Tag noch Sender-Manuskript
haben (z. B. Paywall-Inhalte bei FAZ/Handelsblatt), werden dauerhaft
verworfen. Fuer deutsche Qualitaetsmedien-Podcasts (Dlf, NDR, SZ, Spiegel,
ZEIT wo frei) reichen die zwei aktiven Stufen.
2026-04-18 12:30:28 +00:00
claude-dev
5127e0a42d Podcast-Integration Phase 1: Feed-Tag + Senderseiten
Podcasts werden wie normale RSS-Quellen behandelt (source_type=podcast_feed).
Kein externer bezahlter Dienst, keine lokale Transkription — Monitor nutzt
ausschliesslich vorhandene Transkripte.

Kaskade fuer Transkript-Bezug:
 1. Podcasting-2.0-Tag <podcast:transcript> im Feed (SRT/VTT/HTML/JSON)
 2. Redaktionelles Manuskript auf der Episodenseite
    (Adapter: Dlf, SZ, Spiegel, NDR)
 3. YouTube-Captions — Phase 2, optional per yt-dlp

Kein Stufen-Treffer -> Episode verworfen (graceful, kein Error).

Neu:
- src/feeds/podcast_parser.py (eigener Parser, RSS-Heisspfad unveraendert)
- src/feeds/transcript_extractors/ (Plugin-Muster):
    __init__.py        Dispatcher, Cache-Lookup gegen podcast_transcripts
    _common.py         HTML-Extraktion, Domain-Matching, httpx-Helper
    rss_native.py      Stufe 1: Feed-Tag-Parser (SRT/VTT/JSON/HTML)
    website_dlf.py     Stufe 2: deutschlandfunk.de + Schwester-Domains
    website_sz.py      Stufe 2: sz.de / sueddeutsche.de
    website_spiegel.py Stufe 2: spiegel.de / manager-magazin.de
    website_ndr.py     Stufe 2: ndr.de

Geaendert:
- src/database.py: idempotente Migration, Tabelle podcast_transcripts als
  URL-Cache gegen Mehrfach-Scrape zwischen Lagen
- src/models.py: Pydantic-Pattern von source_type um podcast_feed erweitert
- src/source_rules.py: get_feeds_with_metadata() nimmt source_type-Parameter,
  Default rss_feed (RSS-Pfad unveraendert)
- src/agents/orchestrator.py: neue _podcast_pipeline() parallel zu RSS,
  WebSearch und Telegram; nur fuer adhoc-Lagen; ohne Podcast-Quellen dormant

Verifikation:
- Migration auf Live-DB erfolgreich (Log: Tabelle podcast_transcripts angelegt)
- Import-/Instanziierungs-Test aller Module bestanden
- can_handle-Tests pro Sender-Adapter positiv + negativ OK
- Live-Scrape gegen Dlf: 22710 Zeichen, gegen SZ: 24918 Zeichen
- Dormant-Test: 0 Podcast-Quellen -> keine neue Codezeile im Refresh

Verwerfbarkeit: rein additiv, RSS-Pfad unberuehrt, Rollback in drei
Schritten (Quellen disablen, git revert, DROP TABLE podcast_transcripts).
2026-04-18 12:06:54 +00:00
claude-dev
d6c541cb95 Neueste Entwicklungen: Kachel fuer adhoc-Lagen
- DB-Migration: Spalte latest_developments (TEXT) in incidents
- Analyzer: neuer Prompt LATEST_DEVELOPMENTS_PROMPT_TEMPLATE und
  Methode generate_latest_developments() liefert chronologische
  Bullet-Liste (max. 8, neueste oben, Zeitstempel DD.MM. HH:MM)
- Orchestrator: nach Analyse+Faktencheck ein Extra-Schritt nur fuer
  incident_type=adhoc, der die neue Kachel fortschreibt
- Analyzer-Prompts (Erst- und inkrementell): erzeugen KEINE
  Zusammenfassung-Sektion mehr im Lagebild (vermeidet Duplikat mit
  der neuen Kachel)
- models.IncidentResponse um latest_developments erweitert
- Frontend: Rendering der Kachel in app.js
2026-04-18 11:47:10 +00:00
claude-dev
acfc74ffe7 Standard-Opus auf claude-opus-4-7 festlegen (statt CLI-Default) 2026-04-16 22:19:26 +00:00
Claude
0ea7f9e305 report-export: verlinkte Zitate in Zusammenfassung und Bericht 2026-04-14 17:55:01 +00:00
claude-dev
def12ecf11 Scrolling fuer Zusammenfassung-Kachel hinzugefuegt
Zusammenfassung-Content-Container bekommt overflow-y: auto und
Scrollbar-Styling analog zu Lagebild, Faktencheck und Timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:25:14 +00:00
claude-dev
3379151fa7 Export: Alle verbleibenden Grautöne auf Navy #0a1832 für Drucklesbarkeit
- PDF: Seitenzahlen, Timeline-Datum/-Quelle, Report-Footer, Lagebild-Timestamp
- DOCX: Dokument-Footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 01:17:57 +00:00
claude-dev
048c347616 PDF-Export: Inhaltsverzeichnis + Seitenumbrüche pro Abschnitt
- Seite 2: Dynamisches Inhaltsverzeichnis mit klickbaren Anker-Links
- Nur ausgewählte Bereiche erscheinen im Verzeichnis (CSS Counter)
- Jeder Abschnitt beginnt auf neuer Seite (page-break-before)
- Redundante Inline-Styles für Seitenumbrüche entfernt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 01:15:23 +00:00
claude-dev
96463824a7 Export: Executive Summary → Zusammenfassung, Deckblatt-Farben druckfähig
- Alle sichtbaren "Executive Summary"-Bezeichnungen durch "Zusammenfassung" ersetzt
  (PDF/DOCX-Überschrift, Dateiname, Fallback-Texte)
- Deckblatt-Farben von #888/#aaa auf Navy #0a1832 geändert für
  bessere Lesbarkeit beim Druck (PDF-Template + DOCX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:42:55 +00:00
claude-dev
4358020c83 Export-Fix: published_at als int abfangen (TypeError)
pub kann int sein statt String -- str() Konvertierung vor Slicing.
2026-04-11 22:51:26 +00:00
claude-dev
509165484e Zusammenfassung-Kachel: Quellenverweise als klickbare Links
renderZusammenfassung bekommt jetzt sourcesJson und rendert
[1], [2] etc. als klickbare Links -- identisch zu renderSummary.
2026-04-11 22:34:04 +00:00
claude-dev
db662f4538 Zusammenfassung: Kompatibilitaet mit bestehendem ÜBERBLICK
- Frontend + Backend erkennen jetzt sowohl ## ZUSAMMENFASSUNG als
  auch ## ÜBERBLICK als Zusammenfassungs-Sektion
- Inkrementelles Prompt weist Modell an, ÜBERBLICK in
  ZUSAMMENFASSUNG umzubenennen und als Bullet-Points zu formatieren
- Bestehende Lagen zeigen Zusammenfassung sofort in der Kachel
2026-04-11 22:12:23 +00:00
claude-dev
d2d958e0cd LayoutManager: Zusammenfassung-Kachel registrieren + Layout-Migration
- zusammenfassung in DEFAULT_LAYOUT und TILE_MAP eingetragen
- Toggle-Button funktioniert jetzt (Kachel ein-/ausblenden)
- Migration: Gespeicherte Layouts ohne neue Kacheln werden
  automatisch ergaenzt (kein manueller Reset noetig)
2026-04-11 21:32:45 +00:00
claude-dev
c59ba4f4af Zusammenfassung als eigene Dashboard-Kachel
Research-Lagen: ZUSAMMENFASSUNG-Sektion wird aus dem Bericht
extrahiert und in eigener Kachel oberhalb des Recherchberichts
angezeigt. Der Recherchebericht zeigt den Rest ohne Dopplung.

- Neue Kachel mit gs-id="zusammenfassung" im GridStack
- Toggle-Button in der Layout-Leiste
- extractZusammenfassung() und renderZusammenfassung() in UI
- Adhoc/Live-Lagen: Kachel wird automatisch ausgeblendet
- Export nutzt weiterhin _extract_zusammenfassung() aus dem Backend
2026-04-11 21:12:28 +00:00
claude-dev
1bc8f66283 Export-Dialog: Timeline und Karte als Auswahl entfernt 2026-04-11 20:59:40 +00:00
claude-dev
fa12d4cfd6 Export: Zusammenfassung-Sektion, Checkbox-Auswahl, neue Reihenfolge
Research-Briefings:
- Neue Sektion ZUSAMMENFASSUNG mit Bullet-Points als erstes Element
- UEBERBLICK entfernt, durch ZUSAMMENFASSUNG ersetzt
- Inkrementelles Briefing ebenfalls angepasst

Export-System:
- Zusammenfassung wird direkt aus dem Bericht extrahiert (kein
  separater KI-Aufruf mehr fuer Research-Lagen)
- Reihenfolge: Zusammenfassung > Recherchebericht > Faktencheck > Quellen > Timeline
- Sections-basiert statt scope-basiert (rueckwaertskompatibel)
- Checkbox-Dialog statt Radio-Buttons im Frontend
- Bereiche: Zusammenfassung, Recherchebericht, Faktencheck, Quellen, Timeline, Karte
- PDF und DOCX Templates angepasst
- Backend akzeptiert sections-Parameter (kommagetrennt)
2026-04-11 20:56:04 +00:00
claude-dev
89cc920bdc Warteschlange: Positionen nach Cancel/Error/Complete neu nummerieren
Wenn ein Fall aus der Queue entfernt wird (Cancel, Fehler, Abschluss),
bleiben die #-Nummern der verbleibenden Eintraege jetzt nicht mehr
stecken. _reindexQueuePositions() sortiert nach alter Position und
nummeriert sequentiell neu (#1, #2, ...).

Aufgerufen in: handleRefreshCancelled, handleRefreshError,
handleRefreshComplete.
2026-04-11 19:50:59 +00:00
claude-dev
f4f1df916e Sofortiger Cancel: Laufende Claude-Prozesse per Event abbrechen
Bisher war Cancel kooperativ (Flag-basiert) -- der Code pruefte das Flag
nur an wenigen Checkpoints. Laufende Claude CLI Subprozesse (WebSearch,
Analyse, Faktencheck) liefen bis zum Ende weiter, was minutenlanges
Warten beim Abbrechen verursachte.

Neuer Ansatz:
- ContextVar _cancel_event_var in claude_client.py
- Orchestrator setzt asyncio.Event vor jedem Refresh
- call_claude wartet parallel auf Prozess UND cancel_event
- Bei Cancel: process.kill() + CancelledError sofort
- Kein Durchreichen durch Agent-Methoden noetig (contextvars)
2026-04-11 19:29:01 +00:00
claude-dev
7900c38882 Enhance-Prompts: Rolle als Recherche-Planer klarstellen, Verweigerungen verhindern
Beide Prompts (Research + Adhoc) definieren jetzt explizit:
- Modell ist Recherche-Planer, nicht Faktenbehaupter
- Thema muss nicht bekannt oder verifiziert werden
- Briefing IMMER erstellen, keine Rueckfragen/Disclaimer
- Recherche-Schwerpunkte praxisnaeher formuliert

Behebt sporadische Verweigerungen bei unbekannten Faellen.
2026-04-11 19:03:50 +00:00
claude-dev
6cddb05b83 fix: Quellen-Suffix-Refs ([22b]) auf Basisquelle auflösen statt Platzhalter
Claude vergibt manchmal Buchstaben-Suffixe an Quellennummern (z.B. [22b] statt [22]). Bisher wurden dafür leere Platzhalter-Quellen erstellt. Jetzt wird geprüft ob die Basisnummer existiert und die Referenz im Text korrigiert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:51:33 +00:00
claude-dev
5a56024501 top_articles pro Location in Lagebild-API ergänzen
_build_lagebild_response() liefert jetzt Top-3-Artikel (neueste)
pro Location für Karten-Popups mit klickbaren Quellen-Links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:55:35 +00:00
claude-dev
68c4e2a9c9 Generischen Lagebild-API-Endpunkt hinzufügen
Shared-Logik extrahiert (_build_lagebild_response, _get_snapshot_response).
Neue Endpunkte:
- GET /api/public/lagebild/{incident_id} für beliebige öffentliche Lagen
- GET /api/public/lagebild/{incident_id}/snapshot/{snapshot_id}
Bestehende Iran-Endpunkte bleiben abwärtskompatibel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:09:05 +00:00
claude-dev
f2469093ee Revert "Feature: Analyse-Anweisungen (Direktiven) fuer Tabellen und Zusammenfassung"
This reverts commit e0bcd85d90.
2026-04-10 19:34:25 +00:00
claude-dev
e0bcd85d90 Feature: Analyse-Anweisungen (Direktiven) fuer Tabellen und Zusammenfassung
Nutzer koennen per Klick auf Chips Anweisungen zur Beschreibung
hinzufuegen: Zusammenfassung, Vergleichstabelle, Zeitverlauf,
Pro/Contra oder eigene Tabellen. Format: [TABELLE: ...] und
[ZUSAMMENFASSUNG]. Mehrere Anweisungen moeglich. Analyzer-Prompts
beachten diese Anweisungen verbindlich. Beschreibung-generieren
bewahrt bestehende Direktiven.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:37:04 +00:00
claude-dev
565ce84abf Feature: Markdown-Tabellen in Lagebildern
Analyzer-Prompts erlauben jetzt Tabellen wenn Daten sich strukturiert
vergleichen lassen (Produkte, Modelle, Kennzahlen etc.).
Frontend parst Markdown-Tabellensyntax und rendert sie als HTML-Tabellen
mit passendem Styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:41:07 +00:00
claude-dev
e2e6a1ed7e Perf: Executive Summary nach Refresh im Hintergrund vorberechnen
Statt beim PDF-Export 30+ Sekunden auf die KI-Zusammenfassung zu
warten, wird sie jetzt automatisch nach jedem Refresh generiert.
Beim Export ist sie dann sofort verfuegbar (gecacht in DB).
Summary-Aenderungen invalidieren den Cache automatisch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:31:20 +00:00
claude-dev
d15afdd2af Fix: Sidebar bleibt klickbar während erster Recherche
Sidebar bekommt z-index 9500 (über dem Progress-Overlay mit 9000),
sodass man während der ersten Recherche einer neuen Lage andere
Fälle in der Sidebar anklicken und damit weiterarbeiten kann.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:33:06 +00:00
claude-dev
521d6ac357 Fix: Artikel inline nachladen wenn all_articles_preloaded fehlt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:12:06 +00:00
Claude Dev
3f9cc5a6e0 Fix: HTTPBearer gibt 401 statt 403 bei fehlendem Token
HTTPBearer(auto_error=True) gab 403 zurueck wenn kein Authorization-
Header gesendet wurde. Das Frontend erkennt nur 401 als Session-Ablauf
und leitet zum Login weiter. 403 wurde als generischer Fehler behandelt,
wodurch abgelaufene Sessions still fehlschlugen (kein Redirect zum Login).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:21:33 +02:00
Claude Dev
55c0307e68 Fix: Faktencheck bei fehlenden existing_facts trotz vorhandener Summary
Wenn ein vorheriger Refresh die Summary gespeichert hat aber die
Faktenchecks durch einen Crash verloren gingen, wurden bei allen
Folge-Refreshes keine Artikel an den Factchecker uebergeben
(all_articles_preloaded blieb None), was zu leeren Ergebnissen fuehrte.

Betroffen: Incidents 56, 57, 58 (alle mit 0 Faktenchecks trotz Artikeln).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:15:58 +02:00
Claude Dev
3bf4f3debb Fix: Redundanten lokalen Import von find_matching_claim entfernt
Der lokale Import in der Massen-Downgrade-Pruefung ueberschattete den
Top-Level-Import und verursachte: cannot access local variable
find_matching_claim where it is not associated with a value.

Dadurch scheiterte der Faktencheck komplett (0 Faktenchecks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:31:31 +02:00
Claude Dev
9aa80b4aec Lagenwechsel: Popup/Mini-Status pro Lage korrekt wiederherstellen
Beim Zurueckwechseln auf eine laufende Lage wird der gespeicherte
State (minimized/offen) direkt aus _progressState wiederhergestellt.
War das Popup offen -> offen. War es minimiert -> Mini-Bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:57:51 +02:00
Claude Dev
fb0c47eee4 Fix: Abbrechen-Dialog, Overlay-Stacking, Queue-Cancel
1. Confirm-Dialog z-index ueber Progress-Popup (10000 > 9000)
2. Progress-Popup wird ausgeblendet waehrend Confirm-Dialog offen
3. Kein dunkler-werdendes Overlay bei mehrfachem Abbrechen-Klick
4. Abbrechen funktioniert jetzt auch fuer Lagen in der Warteschlange
   (werden direkt aus der Queue entfernt statt auf Start zu warten)
5. Cancel-Status wird im Popup-Titel angezeigt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:50:30 +02:00
Claude Dev
990ece1346 Fix: Sidebar-Status bei Fehler und Abbruch korrekt aufraumen
handleRefreshError und handleRefreshCancelled haben den Sidebar-
Refresh-Status (Gold-Rand, Spinner-Text) nicht entfernt. Dadurch
blieben Lagen faeelschlicherweise als laufend markiert.

Jetzt wird bei Error/Cancel: _removeSidebarRefreshStatus() aufgerufen,
_progressState geloescht, und Sidebar neu gerendert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:48:24 +02:00
Claude Dev
3811229ad9 Ctrl+Shift+R: Refresh-Status korrekt wiederherstellen
API /incidents/refreshing gibt jetzt auch queued IDs mit Position
und den aktuell laufenden Task zurueck.

Frontend nutzt started_at aus der API fuer Timer-Wiederherstellung.
Queued Lagen werden mit korrekter Position wiederhergestellt.
Aktiv laufender Task wird als researching angezeigt statt queued.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:40:33 +02:00
Claude Dev
c349947f71 Sidebar: Queue-Position bleibt bei Lagenwechsel erhalten
Position aus _progressState lesen als Fallback wenn extra.queue_position
nicht verfuegbar ist (z.B. bei Lagenwechsel-Restore).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:38:30 +02:00
Claude Dev
762d8dbc1a Sidebar: Refresh-Status fuer ALLE Lagen korrekt anzeigen
Progress-State wird jetzt fuer alle refreshenden Lagen angelegt,
nicht nur fuer die aktuell ausgewaehlte. Sidebar-Update passiert
vor dem Early-Return fuer nicht-aktuelle Lagen.

Bei WebSocket-Reconnect (auch nach Ctrl+Shift+R) wird der State
fuer bereits laufende Refreshes korrekt wiederhergestellt.

Sidebar-Cleanup bei Refresh-Abschluss fuer alle Lagen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:36:16 +02:00
Claude Dev
244cc56bde Sidebar: Refresh-Status direkt im HTML gerendert statt dynamisch
renderIncidentItem() baut den Refresh-Status (Gold-Rand, Spinner,
Statustext, Warteschlange-Position) direkt ins HTML ein. Ueberlebt
jetzt renderSidebar()-Aufrufe bei Lagenwechsel und Aktualisierungen.

Sidebar wird nach jedem WebSocket-Status-Update neu gerendert,
damit der Status fuer ALLE Lagen immer sichtbar bleibt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:32:37 +02:00
Claude Dev
9bfdf051c9 Fortschritt: Popup minimiert sich bei Klick ausserhalb automatisch
Klick irgendwo ausserhalb des Popups minimiert es zur Mini-Bar.
Beim ersten Durchlauf (blocking) bleibt das Popup offen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:25:51 +02:00
Claude Dev
86ff35977e Fortschritt: Popup wird immer zu Beginn angezeigt
Auto-Minimize entfernt. Popup erscheint sowohl bei erstem Durchlauf
als auch bei Aktualisierung sofort. Kann dann manuell minimiert werden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:23:42 +02:00
Claude Dev
97ecde87c2 Fortschritt: Auto-Minimize, Action-Lock, Queue-Anzeige in Sidebar
1. Aktualisierungen starten minimiert (Mini-Bar), Popup nur per Klick.
   Verhindert Ueberlagerung von Bearbeiten/Export-Buttons.
2. Erster Durchlauf: Bearbeiten/Export/Archivieren/Loeschen gesperrt,
   nur Abbrechen moeglich.
3. Sidebar: Warteschlange-Lagen zeigen Position (#1, #2...) mit
   eigenem visuellen Stil (gedimmt, pulsierender Dot).
4. Sidebar-Status (Recherchiert/Analysiert/Faktencheck) wird fuer
   ALLE laufenden Lagen angezeigt, nicht nur die aktuelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:21:52 +02:00
Claude Dev
3f88d00b8c Fortschrittsanzeige: Popup mit Checkboxen, Blur, Pro-Lage-Timer
Ladebalken ersetzt durch zentriertes Popup-Fenster mit Checkbox-Checkliste
(Warteschlange, Recherche, Analyse, Faktencheck) und Echtzeit-Timer.

Erster Durchlauf: Popup nicht wegklickbar, Blur-Effekt auf Kacheln.
Aktualisierung: Popup minimierbar zu kompakter Status-Leiste.
Timer laeuft pro Lage im Hintergrund weiter bei Lagenwechsel.
Gesamtzeit wird am Ende im Abschluss-Popup angezeigt.

Sidebar: Animierter Gold-Rand und Fortschrittstext (Recherchiert/
Analysiert/Faktencheck) unter dem Lage-Namen bei laufendem Refresh.

Zusaetzlicher Cancel-Checkpoint im Orchestrator nach Uebersetzung.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:59 +02:00
Claude Dev
3356ba1ae5 E-Mail-Benachrichtigungen: Lagebild vs. Recherche unterscheiden
Template passt Text je nach incident_type an:
- adhoc: "Neues Lagebild - Benachrichtigung" / "Neuigkeiten zur Lage"
- research: "Recherche - Benachrichtigung" / "Neuigkeiten zur Recherche"

incident_type wird durch die gesamte Notification-Kette durchgereicht.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:49:59 +02:00
Claude Dev
ac3fe5f22b Fix: Massen-Downgrade von Faktenchecks verhindern
Zwei Bugs behoben die dazu fuehrten, dass alle established Faktenchecks
bei einem inkrementellen Refresh auf unverified zurueckgesetzt wurden:

1. _format_existing_facts() uebergibt jetzt Evidence-Kontext an den LLM,
   damit bestehende Claims im inkrementellen Modus verifiziert bleiben.
2. Neuer Schutz im Orchestrator: Wenn >50% der established Fakten
   herabgestuft wuerden, werden die FC-Ergebnisse komplett verworfen.

Root Cause: Inkrementeller Faktencheck hatte nur Claims+Status aber
keine Evidence. Der LLM konnte bestehende Fakten nicht verifizieren
und gab unverified fuer alles zurueck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:46:16 +02:00
Claude Dev
678b72e7ff fix: Dropdown bleibt offen beim Klick auf Org-Switcher
stopPropagation auf dem Dropdown-Container verhindert, dass der
globale Click-Listener das Menue schliesst wenn man den Org-Switcher
bedient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:35:43 +02:00
Claude Dev
c22ae854fe feat: Global-Admin Org-Switcher fuer info@aegis-sight.de
Ermoeglicht dem Global Admin (is_global_admin Flag) zwischen
Organisationen zu wechseln. Neue Endpoints: GET /api/auth/organizations,
POST /api/auth/switch-org. Org-Dropdown im Header-Menue, nur fuer
Global Admin sichtbar. Komplett herausnehmbar (Flag + Code-Bloecke).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:25:41 +02:00
Claude Dev
d3e8c0adc7 feat: Slot-basierter Auto-Refresh mit konfigurierbarer Startzeit
Auto-Refresh nutzt jetzt eine feste Anker-Uhrzeit (refresh_start_time) statt
reinem Intervall-basiertem Driften. Verpasste Slots werden max. 1x aufgeholt.
Bestehendes Intervall-Verhalten bleibt als Fallback erhalten (ohne Startzeit).

Migration: Bestehende Auto-Lagen erhalten 07:00 als Startzeit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:14:34 +02:00
Claude Dev
68c6666d87 cleanup: Blog-Pipeline entfernt (läuft jetzt auf Dev)
Die Blog-Pipeline wurde auf den Dev-Server migriert und läuft dort
als eigenständiger Service im Blog-Container. Die Monitor-seitige
Implementation wird nicht mehr benötigt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:54:54 +02:00
Claude Dev
b58eee2990 fix: Gedankenstriche durch Bindestriche statt Kommas ersetzen 2026-03-29 16:41:20 +02:00
Claude Dev
4a3b6ee352 fix: Pipeline 4/4 Artikel erfolgreich
- _extract_json: Typographische Anfuehrungszeichen ersetzen
- _extract_json: Detailliertes Error-Logging bei Parse-Fehlern
- Writer-Prompt: Regel 5 verbietet doppelte Anfuehrungszeichen
  im Markdown (brechen JSON-String-Werte)
- json.loads(strict=False) fuer rohe Newlines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:26:07 +02:00
Claude Dev
8baa4b4716 fix: Pipeline JSON-Parsing robust (first-open-to-last-close + strict=False)
- _extract_json: Neuer Ansatz findet erstes { bis letztes } statt
  fragiler Codeblock-Regex (loest Problem mit Backticks im Markdown)
- json.loads(strict=False) ueberall: Erlaubt rohe Newlines in Strings
  (Claude liefert content_markdown mit echten Newlines statt \n)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:21:07 +02:00
Claude Dev
144b7c05c9 fix: Pipeline JSON-Parsing + defensive Zugriffe
- Curator/Writer: Doppelt-encodiertes JSON abfangen (isinstance + json.loads)
- Pipeline: .get() statt direkte Dict-Zugriffe gegen TypeError
- TypeError zum except-Block hinzugefügt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:53:42 +02:00
Claude Dev
c53d441c69 fix: JWT_SECRET lazy-validiert statt beim Import
config.py: get_jwt_secret() wirft RuntimeError nur bei Nutzung,
nicht beim Import. Blog-Pipeline kann importieren ohne JWT_SECRET,
Monitor bleibt geschützt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:53:17 +02:00
Claude Dev
5bcaa4e8a1 fix: Blog-Pipeline lauffähig + robust
- Shell-Script: source .env statt dotenv (K1+K2)
- config.py: JWT_SECRET Default statt Crash beim Import (M17)
- JSON-Parsing: Robuste Extraktion aus Claude-Antworten (M16)
- Push-Retry mit exponentiellem Backoff (N8)
- open() mit with-Statement (N9)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:47:44 +02:00
Claude Dev
c21fdcef05 fix: Blog-Pipeline sys.path für config-Import 2026-03-29 04:35:36 +02:00
Claude Dev
b3c8cf2676 feat: Blog-Pipeline (Curator + Writer + Push) 2026-03-29 03:35:43 +02:00
Claude Dev
cb851ee72d fix: Textarea-Höhe beim Öffnen von Neuer Fall zurücksetzen 2026-03-28 00:58:14 +01:00
Claude Dev
34a173b27b fix: Textarea Auto-Resize + Emdashes entfernt
Beschreibungs-Textarea waechst automatisch mit dem Inhalt (min 80px).
Greift beim Tippen, nach Beschreibung generieren und im Edit-Modus.
Emdashes (U+2014) durch Doppelpunkte ersetzt.
2026-03-28 00:51:02 +01:00
Claude Dev
779678fbcb fix: Beschreibung generieren Button korrekt enabled/disabled
Button wird beim Bearbeiten einer Lage korrekt aktiviert (Titel per
JS gesetzt loest kein input-Event aus). Im Create-Modus explizit
disabled nach form.reset().
2026-03-28 00:39:01 +01:00
Claude Dev
322004e0b4 fix: Beschreibung generieren gibt jetzt reinen Fließtext aus
Neuer raw_text Parameter in call_claude() umgeht den JSON-System-Prompt.
Haiku gibt direkt lesbaren Text zurück statt JSON-Objekte.
Gesamtes JSON-Parsing (_json_to_text, Markdown-Strip) entfernt.
2026-03-28 00:20:36 +01:00
Claude Dev
813b3d975e fix: Markdown-Code-Block-Wrapper vor JSON-Parse entfernen
Claude CLI gibt bei tools=None oft Antworten in Markdown-Code-Blocks
zurueck (dreifache Backticks json...Backticks). Diese werden jetzt vor
dem JSON-Parse per Regex entfernt.
2026-03-28 00:11:01 +01:00
Claude Dev
ebaf35ce2e fix: Verschachtelte JSON-Antworten bei Beschreibung generieren
Haiku gibt oft tief verschachtelte JSON-Objekte zurück statt reinem
Text. Neue _json_to_text() Funktion konvertiert beliebige JSON-Strukturen
rekursiv in lesbaren Fliesstext mit Aufzaehlungen.
2026-03-28 00:04:57 +01:00
Claude Dev
0780901b61 fix: Button zeigt Wird generiert... statt nur Spinner 2026-03-27 23:58:14 +01:00
Claude Dev
702ae3cfcf fix: JSON-Wrapping bei Beschreibung generieren bereinigen
call_claude erzwingt bei tools=None JSON-Output per System-Prompt.
Haiku wrapped den generierten Text dann in ein JSON-Objekt.
Fix: JSON parsen und erstes String-Feld extrahieren.
2026-03-27 23:56:28 +01:00
Claude Dev
2c3c3b256a fix: Beschreibung generieren Button rechtsbündig neben Label
Button sitzt jetzt in der Label-Zeile statt unter dem Textarea.
Kompakteres Layout, Label und Aktion auf einer Zeile.
2026-03-27 23:53:34 +01:00
Claude Dev
1ce6b7e609 fix: Formular-Labels nicht mehr in Großbuchstaben
text-transform: uppercase von .form-group label entfernt.
2026-03-27 23:52:14 +01:00
Claude Dev
ca2059aca0 fix: Beschreibungs-Hinweis als Info-Icon Tooltip statt form-hint
Hinweistext zur guten Beschreibung erscheint jetzt als Tooltip am
i-Icon neben dem Label, konsistent mit allen anderen Formularfeldern.
Tooltip-Text wechselt weiterhin je nach Typ (Live/Recherche).
2026-03-27 23:50:09 +01:00
Claude Dev
11d0aadc57 fix: Beschreibung generieren — AbortController, Readonly, Gold-Styling
- Textarea readonly + visuell gedimmt während Generierung läuft
- AbortController bricht Request ab wenn Modal geschlossen wird
- Stern-Icon entfernt, Button-Text in Gold (Akzentfarbe)
- api.js _request() unterstützt externen AbortSignal
2026-03-27 23:46:44 +01:00
Claude Dev
a84e2c108e docs: CLAUDE.md um Prompt Enhancement Feature ergaenzt 2026-03-27 23:31:18 +01:00
Claude Dev
6913c1e683 feat: Beschreibung generieren Button im Neuer-Fall-Modal
KI-gestütztes Prompt Enhancement: Button generiert per Haiku aus dem
Titel eine strukturierte Beschreibung. Unterscheidet zwischen
Live-Monitoring (kompakte Vorfallsbeschreibung) und Recherche
(strukturiertes Briefing mit Schwerpunkten und Suchbegriffen).

- Neuer Endpoint POST /api/incidents/enhance-description
- Button erscheint für beide Lage-Typen, aktiv ab 3 Zeichen Titel
- Info-Hinweis wechselt je nach Typ mit Beispiel
- Spinner-Animation während der Generierung
2026-03-27 23:31:05 +01:00
Claude Dev
4f8400bfbd docs: CLAUDE.md aktualisiert (neue Serveradresse, fehlende Dateien, Multi-Pass, vollstaendige Architektur) 2026-03-27 23:27:40 +01:00
Claude Dev
bd5952b9ae fix: Multi-Pass nur beim ersten Refresh einer Research-Lage
3 automatische Durchläufe laufen nur wenn noch kein Briefing existiert
(erster Refresh). Folge-Refreshes machen wie bisher einen einzelnen
Durchlauf, um unnötige Token-Kosten zu vermeiden.
2026-03-27 18:48:43 +01:00
Claude Dev
506965e3e2 feat: Research-Modus führt automatisch 3 Durchläufe durch
Research-Lagen (Tiefenrecherche) führen jetzt bei einem Klick auf
Aktualisieren automatisch 3 aufeinanderfolgende Refresh-Zyklen durch:
1. Breite Erfassung (initiale 4-Phasen-Recherche)
2. Vertiefung (andere Quellen, inkrementelle Analyse)
3. Konsolidierung (letzte Lücken, Fakten-Upgrade auf established)

Die Progress-Bar zeigt den aktuellen Durchlauf an (Durchlauf 1/3 etc.).
Cancel funktioniert zwischen und innerhalb der Durchläufe.
Adhoc-Lagen (Live-Monitoring) sind nicht betroffen.
2026-03-27 18:38:06 +01:00
Claude Dev
a5ef9bbfbf fix: Refresh-Button zeigt Warteschlangen-Status statt Fehlermeldung
Bei Klick auf Aktualisieren bleibt der Progress-Indikator aktiv,
auch wenn die Recherche in der Warteschlange landet. Statt einer
Warning-Meldung wird eine Info-Meldung angezeigt. Erneutes Klicken
bestaetigt, dass die Aktualisierung bereits in Bearbeitung ist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:13:01 +01:00
Claude Dev
6fc0a8c4f6 fix: Sektions-Ueberschrift "Recherchebericht" statt "Analyse"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:54:57 +01:00
Claude Dev
ca271f3822 fix: Recherche-Export zeigt "Analyse" statt "Lagebild" als Sektions-Ueberschrift
Bei Incidents vom Typ research wird in PDF und DOCX nun korrekt
"Analyse" statt "Lagebild" als Ueberschrift verwendet.
Fallback-Texte auf neutrale Formulierung geaendert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:53:45 +01:00
Claude Dev
912257ceef refactor: Token-Tracking mit source=monitor fuer Split-Anzeige
token_usage_monthly hat jetzt UNIQUE(org_id, year_month, source).
Monitor schreibt mit source=monitor, Globe mit source=globe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:44:12 +01:00
Claude Dev
254a518dd8 Cleanup: Backup-Dateien (.bak) entfernt 2026-03-25 23:51:07 +01:00
Claude Dev
d0f99f4e5b Export: Klassifizierung (offen/dienstgebrauch/vertraulich) komplett entfernt 2026-03-25 23:50:57 +01:00
Claude Dev
a2aaa061d4 fix: Keine Gedankenstriche (mdash/endash) in LLM-generierten Inhalten
- Keine-Gedankenstriche-Regel in factchecker.py und researcher.py Prompts
- _sanitize_mdash() in claude_client.py als Sicherheitsnetz: ersetzt
  alle mdash/endash im Output durch Kommas
- analyzer.py hatte die Prompt-Regel bereits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:37:01 +01:00
Claude Dev
5a695ce07c fix(tutorial): Maussimulation Schritt 10 (E-Mail-Benachrichtigungen)
Cursor bewegt sich jetzt zum sichtbaren Toggle-Switch statt zum
versteckten Checkbox-Input. Klick-Animation vor jedem Toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:17:07 +01:00
Claude Dev
0aa2cd09a1 fix(tutorial): Maussimulation fuer Toggle-Schieberegler in Schritt 6+7
Cursor bewegt sich jetzt zum sichtbaren Toggle-Switch statt zum
versteckten Checkbox-Input. Klick-Animation (Scale-Pulse) zeigt
die Betaetigung visuell an.

Neue Hilfsmethoden: _clickAtCursor() und _cursorToToggle()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:16:13 +01:00
Claude Dev
77c89aa13a Report: Lagebericht kompakter — Limits für Faktencheck/Quellen/Quellenverzeichnis
- Lagebild auf ~4000 Zeichen gekürzt (scope=report), Hinweis auf Vollständigen Bericht
- Faktencheck: Top 20 im Lagebericht (alle im Vollständigen)
- Quellenstatistik: Top 20 im Lagebericht
- Quellenverzeichnis: Top 30 im Lagebericht, URLs kleiner (7pt) mit word-break
- Quellenreferenzen [1234] aus Text entfernt
- Sektionsreihenfolge: Exec Summary -> Faktencheck -> Quellenstatistik -> Lagebild
- Lagebericht jetzt ~8-10 Seiten statt 196
2026-03-25 02:05:29 +01:00
Claude Dev
a1c50cfd96 Fix: Description vom Deckblatt entfernt + Code-Fence Bereinigung im Parser
- Deckblatt zeigt nur noch Titel, Typ, Klassifizierung (keine Description)
- Parser entfernt Markdown Code-Fences vor JSON-Parsing
2026-03-25 01:50:47 +01:00
Claude Dev
cc8c6fd268 Fix: Executive Summary Parser — akzeptiert JSON, Markdown und nummerierte Listen 2026-03-25 01:38:37 +01:00
Claude Dev
93948cbc4c Fix: Verwaiste _pdfLabel Referenz entfernt 2026-03-25 01:34:00 +01:00
Claude Dev
f7deafd14a Export-System: PDF/Word mit Executive Summary, Deckblatt, Klassifizierung
- Neuer report_generator.py: WeasyPrint (PDF) + python-docx (Word)
- 3 Stufen: Executive Summary (KI-generiert), Lagebericht, Vollständiger Bericht
- 3 Klassifizierungsstufen: Offen, Nur für den Dienstgebrauch, Vertraulich
- Deckblatt mit AegisSight Logo, Titel, Typ, Klassifizierung
- Executive Summary: Claude Haiku verdichtet Lagebild auf 3-5 Kernpunkte
- Jinja2 HTML-Template für PDF (A4-optimiert)
- Alte Exporte entfernt (Markdown, JSON, Browser-Print)
- Neues Export-Modal im Dashboard (Umfang/Format/Stufe)
2026-03-25 01:28:47 +01:00
Claude Dev
8feaac3320 Fix: Leere Sidebar-Section (doppelter Strich) nach Netzwerk-Entfernung beseitigt 2026-03-25 01:03:40 +01:00
Claude Dev
138fdd8594 Cleanup: Alle Netzwerkanalyse-Reste vollständig entfernt
- 7 JS/CSS-Dateien gelöscht (api_network, app_network, network-graph, network-cluster, cluster-data, network.css, network-cluster.css)
- 2 Backend-Dateien gelöscht (routers/network_analysis.py, models_network.py)
- dashboard.html: Modal Neue Netzwerkanalyse entfernt
- app.js: 15 Netzwerk-Referenzen + kaputte Blöcke bereinigt
- DB-Schema CREATE TABLEs bleiben (geteilte DB mit Netzwerkanalyse-App)
2026-03-25 01:00:03 +01:00
Claude Dev
dd25daa253 Netzwerkanalyse in eigenständige App ausgelagert
- Router-Import und Einbindung entfernt
- Dashboard: Netzwerk-UI, CSS, JS-Referenzen entfernt
- Netzwerk-Sidebar-Sektion und network-view entfernt
2026-03-25 00:40:29 +01:00
Claude Dev
285dfbebce Einheitliches AegisSight Favicon (SVG) auf allen Seiten
- favicon.svg (AegisSight Schild-Logo) hinzugefügt
- index.html + dashboard.html: PNG/ICO-Referenzen durch SVG ersetzt
2026-03-25 00:12:55 +01:00
Claude Dev
eaf8fcd124 Fix: Echte Umlaute statt Umschreibungen (gültig, für, prüfen) 2026-03-25 00:04:41 +01:00
Claude Dev
8f1a45c1a9 Auth: Nur noch Magic Link, Code-Verifizierung entfernt
- /api/auth/verify-code Endpoint entfernt
- generate_magic_code() und VerifyCodeRequest entfernt
- VerifyCodeLimiter (Brute-Force-Schutz) entfernt (nicht mehr noetig)
- E-Mail-Template: Nur noch Anmelde-Link, kein 6-stelliger Code
- Login-Seite: Zeigt nach E-Mail-Eingabe Hinweis statt Code-Feld
- Magic Link Token-Verifikation via URL bleibt bestehen
2026-03-25 00:01:19 +01:00
Claude Dev
5789cc1706 globe-feed komplett neu geschrieben (unhashable dict Fix)
Saubere Implementierung mit set() fuer Artikel-Deduplizierung.
54 Features mit ortsspezifischen Artikeln fuer Lage 45.
2026-03-24 14:47:28 +01:00
Claude Dev
8a520389c5 globe-incidents: Nur aktive Live-Monitorings, keine Recherchen/Archive 2026-03-24 13:45:17 +01:00
Claude Dev
42591ef7e0 globe-feed: Vollstaendige Artikel-Details pro Standort 2026-03-24 13:41:39 +01:00
Claude Dev
da7f3822c1 API: globe-incidents Endpoint fuer Lage-Auswahl im Globe 2026-03-24 13:30:52 +01:00
Claude Dev
3d270f60d3 Globe-Ingest: Neuer Endpoint POST /api/public/globe-ingest
Nimmt externe Ereignisse (EONET, USGS) als Artikel in eine Lage auf.
Duplikat-Check per Headline. Locations direkt mit Koordinaten.
2026-03-24 13:27:17 +01:00
Claude Dev
094f2463bb Neuer API-Endpoint: /api/public/globe-feed fuer Globe-Integration
Liefert Locations + Artikel-Headlines + Summaries als GeoJSON.
Flexible Lage-Auswahl per incident_id oder alle oeffentlichen.
Farbkodiert nach Kategorie (primary/secondary/tertiary/mentioned).
2026-03-24 13:11:16 +01:00
Claude Dev
e64447ab7f GEOINT-Modus aus Monitor entfernt
Wird als eigenstaendige Anwendung auf separater Subdomain neu aufgebaut.
Alle GEOINT-Dateien entfernt, dashboard.html/components.js/main.py
auf pre-GEOINT Stand zurueckgesetzt.
2026-03-24 11:06:19 +01:00
Claude Dev
8212617276 Fix: AISStream Auto-Start via @app.on_event(startup)
Router-Level on_event funktioniert nicht in FastAPI,
muss auf app-Level registriert werden. AISStream verbindet
sich jetzt beim Server-Start automatisch und sammelt
kontinuierlich Schiffspositionen (13.000+ global).
2026-03-24 10:56:00 +01:00
Claude Dev
b88b305716 GEOINT: Globaler Schiffsverkehr via AISStream.io
Digitraffic (nur Nordeuropa) ersetzt durch AISStream.io WebSocket:
- Globale Echtzeit-AIS-Daten (tausende Schiffe weltweit)
- Dauerhafter WebSocket-Client im Backend, auto-reconnect
- Schiffsnamen im Popup (MMSI, SOG, COG)
- Binary-Frame Parsing fuer WebSocket-Nachrichten
- Auto-Start bei Server-Hochfahren
- Stale-Cleanup (>15 Min alte Positionen entfernt)
2026-03-24 10:49:23 +01:00
Claude Dev
381313ef12 GEOINT: Globale Flugabdeckung erweitert (64 Stuetzpunkte)
Neue Regionen: Strasse von Hormuz, VAE, Katar, Irak, Jemen,
Rotes Meer, Schwarzes Meer, Marokko, Libyen, Zentralasien,
Florida, Montreal, San Francisco, Denver, Peking, Taiwan,
Chennai, Sri Lanka, Jakarta, Vietnam, Sydney, Neuseeland,
Nairobi, Kapstadt, Lagos, Addis Abeba, Rio, Buenos Aires,
Lima, Bogota.

Schiffsverkehr: Label zeigt "Nordeuropa" (Digitraffic-Abdeckung).
Fuer globale Schiffsdaten waere AISStream.io (API-Key) noetig.
2026-03-24 10:41:39 +01:00
Claude Dev
d53b4552db geoint.js komplett neu geschrieben (Syntax-Fehler durch Patch-Chaos)
Vorherige inkrementelle Patches hatten die Dateistruktur korrumpiert
(orphaned code-Fragmente, fehlende Klammern). Kompletter Neuschrieb
mit allen Features: Flug, Schiff, Erdbeben, GDELT, Heatmap, Koordinaten,
Distanz, Timeline. Saubere Syntax verifiziert.
2026-03-24 10:39:04 +01:00
Claude Dev
a396d63fb2 Fix: GEOINT-Toggle reagiert nicht mehr
Map-Referenz konnte null sein wenn UI._map nach Lage-Wechsel
oder Tile-Parking nicht mehr aktuell war. Jetzt dreifacher
Fallback: uebergebene map -> GEOINT._map -> UI._map.
Checkbox-Handler nutzt ebenfalls Fallback.
2026-03-24 10:34:08 +01:00
Claude Dev
4dc7824f51 GEOINT: Zoom-adaptives Rendering
Rausgezoomt (Zoom 3-4): Nur 50-80 Marker, kleine Punkte,
  Schiffe nur wenn fahrend (SOG > 1kn)
Mittel (Zoom 5-6): 150-200 Marker, Schiffe ab SOG > 0.3kn
Detail (Zoom 7-9): 400 Marker, alle Schiffe, groessere Punkte
Nah (Zoom 10+): 600-800 Marker, volle Details, grosse Punkte

Marker-Groesse skaliert mit Zoom (2-4px).
Verhindert Browser-Ueberlastung bei Weltansicht.
2026-03-24 10:31:52 +01:00
Claude Dev
b248c7e039 GEOINT Performance-Fix: Canvas-Renderer statt DOM-Marker
Vorher: 18.000+ DOM-Elemente (divIcon) -> Browser-Absturz
Jetzt: Canvas-basierte circleMarker (L.canvas Renderer)
- Flugzeuge: max 400 im sichtbaren Bereich, gruene Punkte
- Schiffe: max 500 im sichtbaren Bereich, blaue Punkte
- Canvas rendert tausende Marker als ein einzelnes HTML-Element
- Popups weiterhin per Klick verfuegbar
2026-03-24 10:31:14 +01:00
Claude Dev
9825f4df48 GEOINT: Stabile Flug-/Schiffsdaten ohne Verzögerung oder Verschwinden
Architektur umgebaut: Daten werden global gecacht, Rendering ist
client-seitig. Nur sichtbare Marker werden gerendert (bounds-Filter).
Bei moveend (Zoom/Pan) wird sofort aus dem Cache neu gerendert (300ms),
kein neuer API-Call noetig. API-Refresh bleibt 30s/60s im Hintergrund.

Fix: fehlender break im flights switch-case (ships wurden gestoppt).
2026-03-24 10:30:11 +01:00
Claude Dev
7f09375aed GEOINT: Globaler Flugverkehr + Schiffsverkehr-Layer
Flugverkehr: Globaler Snapshot ueber 29 Stuetzpunkte weltweit.
Backend aggregiert parallel, 30s Cache, kein Flackern (atomarer Swap).
Keine regionale Begrenzung mehr.

Schiffsverkehr: Neuer Layer via Digitraffic AIS API (kostenlos, kein Key).
18.000+ Schiffe global, 60s Refresh. Blaue Schiffs-Icons mit Heading-Rotation.
Popup zeigt MMSI, SOG, COG, Navigationsstatus.

Backend: Batch-Fetching mit asyncio.Lock gegen Race Conditions.
2026-03-24 10:25:46 +01:00
Claude Dev
eebbc82e3f GEOINT Flugverkehr: Kein Flackern, stabiler
- Atomarer Layer-Swap statt clearLayers (Marker verschwinden nie)
- Einzelner API-Call (radius=250nm) statt 4 Grid-Calls (weniger Rate-Limits)
- Refresh-Interval 15s -> 30s (weniger API-Last)
- moveend-Debounce 1.2s -> 2s (ruhigeres Verhalten beim Navigieren)
- Backend Cache-TTL 10s -> 20s, Koordinaten auf 0.5-Grad-Raster gerundet
2026-03-24 10:20:37 +01:00
Claude Dev
db5aa965bd GEOINT Flugverkehr: Lesbarkeit + vollstaendige Abdeckung
Popup: Dunkler Hintergrund (rgba(11,17,33,0.95)) mit gruener Border,
weisse Schrift statt grau, groesser (12/13px). Taktischer Look.

Abdeckung: Grid-basiertes Multi-Point-Fetching statt einzelnem Mittelpunkt.
Kartenbereich wird in bis zu 2x2 Zellen unterteilt, pro Zelle ein API-Call.
Duplikate per hex-ID gefiltert. Deckt jetzt den gesamten sichtbaren
Bereich ab, auch bei weitem Zoom oder Breitbild-Viewports.
2026-03-24 10:17:33 +01:00
Claude Dev
8d5eb91383 GEOINT Flugverkehr: Zoom-Guard gelockert und Debounce erhoeht
- Zoom-Minimum von 5 auf 3 gesenkt (Europa-Uebersicht nutzbar)
- moveend-Debounce von 600ms auf 1200ms (weniger Flackern beim Zoomen)
2026-03-24 10:14:09 +01:00
Claude Dev
ffcf54785d GEOINT Distanz: Bessere Sichtbarkeit auf Satellitenbildern
- Rote Linie mit schwarzem Outline statt duenner gelber Linie
- Groessere Endpunkte (r=6) mit weissem Rand
- Label groesser (12px, bold, weiss), Schatten fuer Kontrast
2026-03-24 09:57:19 +01:00
Claude Dev
18b7c1f8a0 Fix: CSP blockierte GEOINT-Satellitenbilder und externe APIs
Content-Security-Policy erweitert:
- img-src: server.arcgisonline.com (Esri Satellite Tiles)
- connect-src: earthquake.usgs.gov, api.gdeltproject.org
- script-src: unpkg.com (Leaflet.heat Plugin)
2026-03-24 09:46:42 +01:00
Claude Dev
9941ee646e Fix: GEOINT Satellitenkarte grau - Scanline-Overlay und Tile-Layer-Handling korrigiert
- ::after Pseudo-Element entfernt (ueberdeckte Tiles)
- position:relative vom map-container entfernt
- Tile-Layer Entfernung in separatem Array (vermeidet eachLayer-Mutation)
- pane:overlayPane von Labels entfernt
- bringToBack() auf Satellite-Layer
2026-03-24 09:42:21 +01:00
Claude Dev
b2be1358ab GEOINT-Modus: Experimentelle taktische Kartenansicht mit Echtzeit-Datenlayern
Neuer experimenteller GEOINT-Modus per Checkbox auf der Karten-Kachel:
- Satellitenbilder (Esri World Imagery) statt OSM-Strassenkarte
- Echtzeit-Flugverkehr (airplanes.live via Backend-Proxy, 15s Refresh)
- Erdbeben-Layer (USGS M2.5+, pulsierende Kreise nach Magnitude)
- GDELT Nachrichten (geokodierte Echtzeit-News, Cluster-Darstellung)
- Heatmap-Visualisierung der Artikel-Standorte (Leaflet.heat)
- Timeline-Slider fuer zeitliche Filterung der Artikel-Marker
- Koordinatenanzeige (Lat/Lon unter Mauszeiger)
- Distanzmessung (Klick-zu-Klick mit km-Anzeige)
- Taktisches Styling (dunkle Tonung, gruene Akzente, Scanlines)

Neue Dateien: geoint.js, geoint.css, routers/geoint.py
Inspiriert von WorldView/Gods Eye Konzept, komplett eigenentwickelt.
2026-03-24 09:29:19 +01:00
Claude Dev
fdbffa7e00 Chat-Assistent: UI-Bezeichnungen aktualisiert
Ad-hoc Lage -> Live-Monitoring, Ereignis beobachten
Recherche -> Recherche, Thema analysieren
Erstellen -> Lage anlegen
Lagebild/Recherchebericht Unterscheidung dokumentiert
Neue Regel: AKTUELLE UI-BEZEICHNUNGEN immer verwenden
2026-03-24 08:32:13 +01:00
Claude Dev
d274ec237b Fix: Fehler beim Laden wenn Kacheln ausgeblendet sind
_applyLayout entfernte Widgets ohne die Card-Elemente vorher zu parken.
Beim Wiederherstellen eines Layouts mit versteckten Kacheln (z.B. Timeline)
gingen die DOM-Elemente verloren, was zu null-Referenz-Fehlern fuehrte.

Fixes:
- layout.js: Cards in tile-parking retten bevor Widget entfernt wird
- app.js: Null-Guards in rerenderTimeline und _updateTimelineCount
2026-03-24 07:34:56 +01:00
Claude Dev
c7d7bbbb18 Kachel-Label dynamisch: Recherchebericht bei Recherche-Lagen, Lagebild bei Live-Monitoring
Betrifft: Card-Title, Layout-Toggle, PDF-Export, Benachrichtigungs-Toggle.
Bei Typ research wird ueberall Recherchebericht angezeigt statt Lagebild.
2026-03-24 07:29:14 +01:00
Claude Dev
f60edb42f7 Fix broken source links caused by LLM-generated letter suffixes (e.g. 1383a)
The LLM occasionally generates source references with letter suffixes
(e.g. [1383a], [1396b]) despite being instructed not to. This caused
broken links because the sources array only contained integer nr values.

Backend: Add _sanitize_sources() to strip letter suffixes after parsing
and deduplicate, preferring entries with valid URLs.

Frontend: Add fallback in citation renderer - when a suffix reference
like [1383a] has no matching source with URL, fall back to the base
number [1383].

Also cleaned up 99 broken suffix entries and 44 suffix references in
the Irankonflikt incident (ID 6) database records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:47:02 +01:00
Claude Dev
a136e0625f Fix tutorial leaving tiles extremely small after completion
GridStack v12 removeInternalForSave() deletes w/h properties from
save() output when they equal minW/minH (or 1). This caused
_removeDemoView() to pass h:undefined to grid.update(), which
GridStack then defaulted to h:1, shrinking all tiles.

Fix: save layout by reading node properties directly instead of
using _grid.save(false), ensuring w and h are always preserved.
2026-03-23 23:41:33 +01:00
Claude Dev
c8279bc69b Tutorial: Schritt 21 Spotlight verzögert + Schritt 23 Demo entfernt
- Schritt 21: deferSpotlight verhindert falschen initialen Rahmen
- Schritt 23: Kein disableNav/Demo mehr, normaler Step mit Weiter-Button
2026-03-23 23:34:25 +01:00
Claude Dev
3e3273470b Telegram: Eigene Kategorie statt Ausrichtungs-Kategorisierung
- source_rules: t.me -> telegram in DOMAIN_CATEGORY_MAP
- components: telegram Label in _categoryLabels
- CSS: cat-telegram Badge in Telegram-Blau (#0088cc)
- DB: 106 bestehende Telegram-Quellen auf Kategorie telegram gesetzt
- Tutorial: Telegram in Quellenübersicht-Erklaerung aufgenommen
2026-03-23 23:14:59 +01:00
Claude Dev
360f6bb872 Tutorial: Timeline Spotlight-Fix + Karten-Marker pulsieren
Schritt 21: Spotlight+Bubble werden 300ms nach GridStack-Update neu
positioniert. Kein falscher Rahmen mehr auf der Karte.

Schritt 23: Alle Marker pulsieren kurz (scale 1.8x) bevor der Cursor
sie besucht, damit Nutzer sieht was anklickbar ist.
2026-03-23 23:08:01 +01:00
Claude Dev
073c11431d Tutorial Schritt 21: Bubble unter Kachel, Kachel hoeher, scrollIntoView
- position: bottom statt top (Bubble verschwindet nicht mehr oben)
- Kachel auf min h=5 vergroessert (Detail-Panel passt rein)
- scrollIntoView auf die Kachel damit alles sichtbar ist
2026-03-23 23:02:23 +01:00
Claude Dev
7a804f762c Tutorial: Kein Springen bei allen Steps - Bubble erst nach Clamp sichtbar
Nicht-Modal-Steps: visible nach requestAnimationFrame Clamp+Arrow
Modal-Steps: visible nach 450ms Reposition + Clamp+Arrow
Kein Step zeigt die Bubble mehr an der falschen Position.
2026-03-23 22:58:53 +01:00
Claude Dev
66ecde1d61 Tutorial: Kein sichtbarer Sprung bei Modal-Steps
Bubble wird bei Modal-Steps erst unsichtbar positioniert, dann nach
450ms (wenn Modal-Transition abgeschlossen) korrekt repositioniert
und erst dann sichtbar gemacht.
2026-03-23 22:55:14 +01:00
Claude Dev
9b5c718816 Sidebar: Recherche -> Recherchen 2026-03-23 22:51:58 +01:00
Claude Dev
8bbf7fceac Sidebar: Deep-Research -> Recherche 2026-03-23 22:51:20 +01:00
Claude Dev
e9d1f2ddb3 Wording: Neue Lage -> Neuer Fall, Analyse -> Recherche
Dashboard:
- Button: + Neuer Fall (statt + Neue Lage)
- Modal-Titel: Neuen Fall anlegen
- Dropdown: Recherche - Thema analysieren (statt Analyse - Thema recherchieren)
- Empty-State: neuen Fall statt neue Lage

Tutorial:
- Alle Referenzen auf Neue Lage -> Neuer Fall
- Analyse/Recherche -> Recherche
- Fake-Dropdown: vollstaendige Optionsbeschreibungen
2026-03-23 22:42:56 +01:00
Claude Dev
b712dd5572 Tutorial: Bubble-Reposition nach Modal-Transition (450ms Delay)
Modal braucht Zeit fuer CSS-Transition. Erste Positionierung war falsch
weil getBoundingClientRect() waehrend Animation falsche Werte liefert.
Jetzt wird nach 450ms nochmal komplett repositioniert inkl. Clamp + Arrow.
2026-03-23 22:37:28 +01:00
Claude Dev
ea96947d0f Tutorial: Modal-Bubble immer rechts vom Modal positionieren
Links ist die Sidebar, rechts ist immer der bessere Platz.
Bubble-Breite passt sich an spaceRight an (min 260px).
2026-03-23 22:35:38 +01:00
Claude Dev
17681e62fb Tutorial: Modals automatisch schliessen bei Zurueck-Navigation
_enterStep schliesst alle offenen Modals wenn der neue Step kein
Modal-Step ist. Verhindert Blur auf Seitenleiste und haengengebliebene
Modals bei Rueckwaerts-Navigation (z.B. Schritt 4 -> 2).
2026-03-23 22:31:12 +01:00
Claude Dev
fe62cbbaee Tutorial: Vollstaendige Neupositionierung bei Fenstergroessen-Aenderung
_onResize fuehrt jetzt alle Positionierungsschritte durch:
- Spotlight-Update
- Modal-Scroll zum bubbleTarget
- Bubble-Positionierung mit dynamischer Breite
- Viewport-Clamping (oben + unten)
- Pfeil-Alignment auf Ziel-Element
2026-03-23 22:28:07 +01:00
Claude Dev
1159fe04a0 Tutorial: Pfeil zeigt dynamisch auf Ziel-Element
CSS: --arrow-top Variable fuer left/right Pfeile
JS: Berechnet Pfeil-Position relativ zum bubbleTarget nach Clamping
Pfeil bleibt immer zwischen 18px vom Rand der Bubble
2026-03-23 22:26:56 +01:00
Claude Dev
d299cdbdf4 Tutorial: Bubble wird oben und unten im Viewport geclampt
Verhindert dass Schritt 10 und andere Steps mit weit unten liegenden
Feldern unter den Viewport rutschen.
2026-03-23 22:25:56 +01:00
Claude Dev
7662332714 Tutorial: Bubble-Breite passt sich dynamisch an Platz neben Modal an
Bubble wird auf verfuegbaren Platz (min 260px) verkleinert und immer
auf der Seite mit mehr Platz positioniert. Kein top-Fallback mehr der
die Bubble aus dem Bildschirm schiebt.
2026-03-23 22:24:20 +01:00
Claude Dev
0ffc9b6fb6 Tutorial Schritt 3: Kein Auto-Open mehr, Modal oeffnet erst bei Weiter
Step zeigt nur den Button mit Erklaerung. Modal oeffnet sich erst
beim Klick auf Weiter (im onExit wenn naechster Step ein Modal-Step ist).
2026-03-23 22:21:39 +01:00
Claude Dev
485a527bf6 Tutorial: Race-Condition bei Neustart behoben
- start(forceRestart) awaited jetzt API.resetTutorialState()
- _startInternal awaited API.saveTutorialState(step 0)
- stop() ueberspringt State-Save wenn _isRestarting flag gesetzt
- start() kann jetzt auch bei laufendem Tutorial Restart ausfuehren
2026-03-23 22:17:15 +01:00
Claude Dev
383fe1ca8c Tutorial: Modal-Scroll mit scrollIntoView + scrollTop-Fallback
scrollIntoView scrollt den naechsten scrollbaren Container (modal-body).
Zusaetzlich wird scrollTop direkt gesetzt als Fallback via offsetTop-Berechnung.
2026-03-23 22:14:35 +01:00
Claude Dev
fc5846e878 Tutorial: Modal-Flash beseitigt + Bubble korrekt am Modal positioniert
- Step 3 onExit schliesst Modal nicht mehr wenn naechster Step Modal ist
- Bubble-Positionierung fuer Modal-Steps: prueft Platz links/rechts vom
  Modal statt vom Formularfeld, faellt auf top zurueck bei schmalen Screens
2026-03-23 22:10:20 +01:00
Claude Dev
186efd6aab Tutorial: Modal scrollt automatisch zum Feld beim Betreten jedes Schritts
_enterStep scrollt jetzt bei Modal-Steps zum bubbleTarget bevor
die Bubble positioniert wird. Damit ist das Formularfeld immer
zentriert sichtbar wenn der Schritt erscheint.
2026-03-23 22:04:48 +01:00
Claude Dev
2a8f395b32 Tutorial: Bubble bei Modal-Steps horizontal am Modal ausrichten, vertikal am Feld
Verhindert dass die Bubble ganz links am Rand landet wenn das
Formularfeld breiter als der verfuegbare Platz ist.
2026-03-23 22:03:35 +01:00
Claude Dev
412f869210 Tutorial: Server-State bei Neustart korrekt zuruecksetzen
- forceRestart ruft API.resetTutorialState() auf
- _startInternal setzt Server-State auf Step 0 bei Neustart
- Chat zeigt jetzt 1/31 statt altem Stand
2026-03-23 21:57:10 +01:00
Claude Dev
d2afd102e0 Tutorial: Modal-Felder zentriert scrollen in Schritten 5-10
- _scrollModalTo scrollt Element in die Mitte des sichtbaren Bereichs
- _scrollModalAndReposition repositioniert Bubble nach Scroll
- Alle Modal-Simulationen nutzen zentriertes Scrolling
- Schritt 4+5 nutzen jetzt _scrollModalTo statt manuelles scrollTo
2026-03-23 21:55:48 +01:00
Claude Dev
52358a4f2a Tutorial: bubbleTarget - Pfeil zeigt auf spezifische Formularfelder statt aufs gesamte Modal
Jeder Modal-Schritt hat jetzt ein bubbleTarget:
- Schritt 4: Titel-Feld
- Schritt 5: Typ-Dropdown
- Schritt 6: International-Toggle
- Schritt 7: Sichtbarkeit
- Schritt 8: Aktualisierungsmodus
- Schritt 9: Aufbewahrung
- Schritt 10: Benachrichtigungen
2026-03-23 21:53:20 +01:00
Claude Dev
69922b0566 Tutorial: Spotlight ausblenden bei Modal-Oeffnung in Schritt 3 2026-03-23 21:52:17 +01:00
Claude Dev
c6b154dbba Tutorial Patch 2: Pfeile, Cursor-Z-Index, Modal-Scroll, Karteninteraktion, Layout-Demo, Theme-Toggle
- Schritt 3: Bubble repositioniert sich auf Modal nach Oeffnung
- Schritt 5: Cursor-Z-Index ueber Dropdown (999999)
- Schritt 7ff: Modal scrollt automatisch zu Feldern (async scrollModalTo)
- Schritt 20: Goldener Rahmen um gesamte Faktencheck-Kachel
- Schritt 21: Timeline-Kachel wird temporaer vergroessert
- Schritt 23: Alle Karteninteraktionen deaktiviert (kein Zoom/Click)
- Schritt 25: Drag nach rechts + zurueck, dann Resize vom Original
- Schritt 26: Theme-Toggle-Simulation (hell/dunkel/zurueck)
- Schritt 27: Button bleibt sichtbar nach Quellenverwaltung-Oeffnung
- Spotlight ausgeblendet waehrend Layout-Demo
2026-03-23 21:48:14 +01:00
Claude Dev
584183951f Tutorial: Umfassende Verbesserungen an Schritten 5,8+,11,20-26,28,31,32
- Schritt 5: Simuliertes Dropdown statt einfachem Wechsel
- Ab Schritt 8: Verbessertes Modal-Scrolling fuer alle Formularfelder
- Schritt 11: Cursor-Demo zwischen Alle/Eigene Filtern
- Schritt 20: Status-Durchlauf statt Scroll-Sprung
- Schritt 21: Quellenübersicht nach Timeline/Karte verschoben
- Schritt 22: Timeline mit Cursor-Navigation durch Zeitpunkte
- Schritt 23: Cursor zeigt Orte einlesen + Vollbild Buttons
- Schritt 24: Z-Index Fix fuer Bubble ueber Vollbild-Karte
- Schritt 25/26: Kombinierte Drag+Resize Demo
- Schritt 28/31: Position hoeher (position:top statt right/left)
- Schritt 32: Bubble tiefer zentriert (55% statt 50%)
- 6 neue Simulationsfunktionen hinzugefuegt
2026-03-23 21:23:07 +01:00
Claude Dev
6b4af4cf2a fix: justify-content: center überall wiederhergestellt + Quellen-Duplikatprüfung
- CSS: 24x fälschliches flex-start zurück auf center (Login, Buttons, Modals, Badges, Map etc.)
- Sources: Domain-Duplikatprüfung bei manuellem Hinzufügen (web_source 1x pro Domain, Domain aus URL extrahieren)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:13:36 +01:00
Claude Dev
17088e588f fix: Credits-Dropdown linksbündig, Balken-Track sichtbar, Prozentzahl rechts, kein Fettdruck, mehr Abstand 2026-03-18 00:08:20 +01:00
Claude Dev
97997724de fix: Credits-Anzeige linksbündig, Balken-Hintergrund sichtbar 2026-03-18 00:03:18 +01:00
67 geänderte Dateien mit 12791 neuen und 5511 gelöschten Zeilen

1
.gitignore vendored
Datei anzeigen

@@ -4,3 +4,4 @@ __pycache__/
logs/
data/
.venv/
data

420
CLAUDE.md
Datei anzeigen

@@ -1,190 +1,195 @@
# AegisSight-Monitor
> OSINT-Monitoringsystem mit KI-gestützter Nachrichtenanalyse
> OSINT-Lagemonitoring mit KI-gestützter Nachrichtenanalyse
## Übersicht
```yaml
projekt: AegisSight-Monitor
url: https://osint.intelsight.de
beschreibung: "OSINT-basiertes Lagemonitoring mit Claude-KI-Agenten"
server: alt (91.99.192.14, User: claude-dev)
url: https://monitor.aegis-sight.de
server: ssh monitor (46.225.141.13, User: claude-dev)
pfad: /home/claude-dev/AegisSight-Monitor
datenbank: /mnt/gitea/osint-data/osint.db (geteilt mit AegisSight-Monitor-Verwaltung)
quellcode: /home/claude-dev/AegisSight-Monitor/src/
datenbank: /mnt/gitea/osint-data/osint.db (SQLite WAL, geteilt mit Verwaltungsportal + Globe)
gitea: https://gitea-undso.aegis-sight.de/AegisSight/AegisSight-Monitor
git_push_regel: "Jede Aenderung MUSS sofort committed und nach Gitea gepusht werden."
service: osint-monitor.service (systemd, Port 8891, Nginx Reverse Proxy)
venv: /home/claude-dev/.venvs/osint/
status: aktiv
venv: /home/claude-dev/.venvs/osint/ (Python 3.12)
```
## Technologie-Stack
```yaml
backend:
framework: FastAPI (Python 3.12)
datenbank: SQLite (WAL-Modus, aiosqlite)
auth: Magic-Link-Login per E-Mail (JWT HS256, 24h Ablauf)
framework: FastAPI + Uvicorn
datenbank: SQLite WAL (aiosqlite, async)
auth: Magic-Link-Login per E-Mail (JWT HS256, 24h)
scheduler: APScheduler (Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00)
websocket: FastAPI native (Echtzeit-Updates)
ki_agenten: Claude CLI (WebSearch + WebFetch Tools)
email: aiosmtplib (Magic Links, Benachrichtigungen)
port: 8891 (localhost, Nginx Reverse Proxy)
websocket: FastAPI native (Echtzeit-Updates an Clients)
ki: Claude CLI als Subprocess (WebSearch + WebFetch Tools)
ki_modelle:
schnell: CLAUDE_MODEL_FAST (Haiku) — Feed-Selektion, Geoparsing, Chat, QC
mittel: CLAUDE_MODEL_MEDIUM (Sonnet) — Entity-Extraktion, Netzwerkanalyse
standard: CLI-Default (Opus) — Recherche, Analyse, Faktencheck
email: aiosmtplib (smtp.ionos.de:587 TLS)
frontend:
typ: Vanilla JS (kein Framework)
typ: Vanilla JS (kein Framework, kein Build-Step)
design: AegisSight Dark/Light Theme (Navy/Gold)
fonts: Poppins (Titel), Inter (Body)
layout: gridstack.js (Drag-and-Drop Dashboard-Kacheln)
karte: Leaflet + MarkerCluster
echtzeit: WebSocket mit Auto-Reconnect und Ping/Pong
```
## Projektstruktur
```yaml
AegisSight-Monitor/:
CLAUDE.md: "Diese Datei"
requirements.txt: "Python-Abhaengigkeiten"
data/: "Symlink -> /mnt/gitea/osint-data/ (SQLite DB)"
logs/: "Anwendungs-Logs (osint-monitor.log)"
src/:
main.py: "FastAPI App, WebSocketManager, Scheduler, Lifespan, statische Routen"
config.py: "Konfiguration (JWT, Claude-Modelle, SMTP, RSS-Feeds, Zeitzone)"
auth.py: "JWT erstellen/verifizieren, Magic-Link/Code, get_current_user Dependency"
database.py: "SQLite Schema (25+ Tabellen), Migrationen, init_db(), get_db()"
models.py: "Pydantic Request/Response-Schemas"
source_rules.py: "Domain-Kategorisierung, RSS-Feed-Discovery, Claude-Feed-Bewertung"
report_generator.py: "PDF (WeasyPrint) + DOCX (python-docx) Export"
src/:
main.py: "FastAPI App, WebSocketManager, Scheduler (lifespan), statische Routen"
config.py: "Konfiguration (JWT, Claude CLI Pfad/Timeout, SMTP, RSS-Default-Feeds, Excluded Sources, Zeitzone)"
auth.py: "JWT-Token erstellen/verifizieren, Magic-Link/Code generieren, get_current_user Dependency"
database.py: "SQLite Schema (13 Tabellen), Migrationen, init_db()"
models.py: "Pydantic Request/Response-Schemas"
source_rules.py: "Dynamische Quellen-Regeln aus DB, Domain-Kategorisierung, Feed-Discovery"
routers/:
auth.py: "Magic-Link-Login, Token-Verify, /api/auth/me"
incidents.py: "CRUD Lagen, Refresh, Artikel, Snapshots, Faktenchecks, Export, E-Mail-Abos, Refresh-Log, Beschreibung generieren (Prompt Enhancement)"
sources.py: "CRUD Quellen, Discovery (Single/Multi), Domain sperren, Telegram-Validierung"
chat.py: "KI-Assistent (Haiku), Injection-Schutz, Tech-Leak-Filter"
public_api.py: "API-Key Auth, Globe-Feed (GeoJSON), Globe-Ingest, Snapshot-Abruf"
notifications.py: "CRUD Benachrichtigungen, Unread-Count, Mark-Read"
feedback.py: "E-Mail-Feedback mit Bild-Anhaengen"
tutorial.py: "Tutorial-Fortschritt pro User"
routers/:
auth.py: "Magic-Link-Login: POST /api/auth/magic-link, /verify, /verify-code, GET /api/auth/me"
incidents.py: "CRUD Lagen, Artikel, Snapshots, Faktenchecks, Refresh, Export, E-Mail-Subscriptions"
sources.py: "CRUD Quellen, Discovery (Single/Multi), Domain sperren/entsperren, Stats"
notifications.py: "GET/PUT Benachrichtigungen (Liste, ungelesen, als gelesen markieren)"
feedback.py: "POST /api/feedback (Rate-Limited, E-Mail an feedback@aegis-sight.de)"
agents/:
orchestrator.py: "Queue-basierte Refresh-Steuerung, Research Multi-Pass (3 Durchlaeufe), Retry, Cancel, Credits-Tracking"
researcher.py: "WebSearch-Recherche (Standard + 4-Phasen-Tiefenrecherche), Feed-Selektion, Keyword-Extraktion"
analyzer.py: "Analyse-Agent (Lagebild/Briefing, Erst- + inkrementell, Inline-Zitate)"
factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Triage), Claim-Matching, Dedup"
geoparsing.py: "Haiku-basierte Ortsextraktion, Geocoding via geonamescache"
entity_extractor.py: "Netzwerkanalyse: Entity-Extraktion (Sonnet), Beziehungsanalyse, Dedup"
claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung"
agents/:
claude_client.py: "Shared Claude CLI Client (JSON-Output, Usage-Tracking: Token, Kosten)"
orchestrator.py: "AsyncQueue, Agenten-Pipeline, Cancel, Snapshots, E-Mail-Benachrichtigungen, Quellen-Discovery"
researcher.py: "Claude WebSearch Agent (Ad-hoc + Deep Research Modus)"
analyzer.py: "Analyse-Agent (Zusammenfassung/Briefing mit Inline-Zitaten)"
factchecker.py: "Faktencheck-Agent (Claims gegen unabhaengige Quellen pruefen)"
feeds/:
rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), Keyword-Matching, Domain-Cap"
telegram_parser.py: "Telethon-basierter Telegram-Parser, Kanal-Validierung"
feeds/:
rss_parser.py: "RSS-Feed Aggregation (dynamisch aus DB, Keyword-Matching)"
services/:
post_refresh_qc.py: "Post-Refresh Quality Check: Faktencheck-Duplikate, Location-Korrektur"
fact_consolidation.py: "Periodisches Haiku-Clustering, Auto-Resolve veralteter Fakten"
source_health.py: "Quellen-Health-Checks (Erreichbarkeit, Feed-Validitaet, Stale)"
source_suggester.py: "KI-Quellen-Vorschlaege via Haiku"
license_service.py: "Lizenz-Pruefung (Org, Ablauf, Nutzer-Limit)"
services/:
license_service.py: "Lizenzpruefung (check_license), Nutzer-Limit, Ablauf-Check"
source_health.py: "Quellen-Health-Check Engine (Erreichbarkeit, Feed-Validitaet, Aktualitaet, Duplikate)"
source_suggester.py: "KI-gestuetzte Quellen-Vorschlaege via Claude Haiku"
middleware/:
license_check.py: "Dependencies: require_active_license, require_writable_license"
middleware/:
license_check.py: "FastAPI Dependencies: require_active_license, require_writable_license"
email_utils/:
sender.py: "Async SMTP Versand"
templates.py: "HTML-Templates (Magic-Link, Benachrichtigungen)"
rate_limiter.py: "Rate-Limiting Magic-Links"
migration/:
migrate_to_multitenancy.py: "Einmal-Migration: Single-Tenant zu Multi-Tenant"
migration/:
migrate_to_multitenancy.py: "Einmal-Migration Single->Multi-Tenant"
email_utils/:
sender.py: "Async SMTP E-Mail-Versand (aiosmtplib, TLS)"
templates.py: "HTML-E-Mail-Templates (Magic-Link-Login, Incident-Benachrichtigungen)"
rate_limiter.py: "Rate-Limiting fuer Magic-Links und Code-Verifizierung"
report_templates/:
report.html: "HTML-Template fuer PDF/DOCX-Export"
static/:
index.html: "Login-Seite (Magic-Link: E-Mail eingeben, Code eingeben)"
dashboard.html: "Hauptdashboard (Sidebar + Grid + Modals)"
css/:
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
js/:
api.js: "REST-API-Client (fetch-basiert, 30s Timeout, Auto-Redirect bei 401)"
app.js: "Hauptlogik: ThemeManager, A11yManager, NotificationCenter, App-Objekt"
components.js: "UI-Rendering: Sidebar-Items, Faktenchecks, Evidence-Chips, Toasts, Fortschritt, Quellen"
layout.js: "gridstack.js Wrapper (Drag und Resize, localStorage-Persistenz)"
ws.js: "WebSocket-Client (Reconnect mit exponential Backoff, Ping/Pong)"
static/:
index.html: "Login-Seite (Magic-Link)"
dashboard.html: "Hauptdashboard (Sidebar + GridStack + Modals)"
css/:
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
js/:
api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout)"
app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt"
components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress-Bar, Karte"
chat.js: "Chat-Assistent Widget"
layout.js: "gridstack.js Wrapper (Drag/Resize, localStorage)"
tutorial.js: "Interaktiver 32-Schritte Rundgang mit Animationen"
ws.js: "WebSocket-Client (Reconnect, Ping/Pong)"
vendor/:
leaflet.js: "Karten-Bibliothek"
leaflet.markercluster.js: "Marker-Clustering"
```
## Architektur
```yaml
auth:
methode: "Magic-Link per E-Mail (kein Passwort-Login)"
flow: "E-Mail eingeben, Code per E-Mail, Code eingeben oder Link klicken, JWT"
rate_limiting: "3 Magic-Links pro E-Mail/15min, 5 Fehlversuche Code/E-Mail"
multi_tenancy: "JWT enthaelt tenant_id, org_slug, role"
agenten_pipeline:
1_rss: "RSS-Feeds durchsuchen (nur Ad-hoc-Lagen)"
2_claude_recherche: "Claude CLI WebSearch (Ad-hoc oder Deep Research)"
3_deduplizierung: "URL-Normalisierung + Headline-Aehnlichkeit"
4_analyse: "Zusammenfassung/Briefing mit Inline-Zitaten [1][2]"
5_faktencheck: "Claims gegen unabhaengige Quellen pruefen"
orchestrierung: "Sequentielle AsyncQueue (1 Auftrag gleichzeitig, 3 Retries)"
incident_typen:
adhoc: "Breaking News: RSS + WebSearch, Fliesstext-Summary"
research: "Hintergrundrecherche: Deep Research, Markdown-Briefing"
adhoc:
label: "Live-Monitoring"
quellen: "RSS + WebSearch + optional Telegram"
analyse: "Fliesstext-Lagebild"
faktencheck_status: "confirmed/unconfirmed/contradicted/developing"
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"
refresh: "Immer manuell, erster Refresh automatisch 3 Durchlaeufe (Multi-Pass)"
multi_pass:
durchlaeufe: 3
labels: ["Breite Erfassung", "Vertiefung", "Konsolidierung"]
bedingung: "Nur beim ersten Refresh (kein Summary vorhanden)"
cancel: "Zwischen und innerhalb der Durchlaeufe moeglich"
sidebar:
aktive_lagen: "Lagen mit type=adhoc und status=active"
aktive_recherchen: "Lagen mit type=research und status=active"
archiv: "Alle Lagen mit status=archived (standardmaessig zugeklappt)"
zaehler: "Anzahl pro Sektion in Klammern"
filter: "Alle / Eigene"
refresh_pipeline:
1: "Feed-Selektion (Haiku) + dynamische Keywords"
2: "Parallel: RSS + WebSearch + optional Telegram"
3: "URL-Verifizierung (HEAD-Requests)"
4: "Duplikaterkennung (URL + Headline)"
5: "Relevanz-Scoring + DB-Dedup"
6: "Geoparsing (Haiku + geonamescache)"
7: "Parallel: Analyse + Faktencheck"
8: "Post-Refresh QC"
9: "Notifications (DB + E-Mail + WebSocket)"
10: "Credits-Tracking (Token auf Lizenz buchen)"
11: "Background: Source-Discovery"
benachrichtigungen:
in_app: "NotificationCenter (Glocke + Badge, DB-persistent, 7 Tage)"
email:
einstellung: "Pro Lage konfigurierbar (3 Toggles im Lage-Modal)"
optionen: "Neues Lagebild, Neue Artikel, Statusaenderung Faktencheck"
tabelle: "incident_subscriptions (pro User pro Lage)"
versand: "Nach jedem Refresh (ab dem 2.) basierend auf Subscriptions"
quellenverwaltung:
features: "Anlegen, Bearbeiten, Loeschen, Discovery (Multi-Feed), Domain sperren"
source_types: "rss_feed, web_source, excluded"
lizenz_anzeige:
header: "Org-Name + Lizenz-Badge (Trial/Annual/Permanent/Abgelaufen)"
read_only: "Warnung wenn Lizenz abgelaufen"
multi_tenancy: "Volle Mandantentrennung (tenant_id auf allen Tabellen)"
dashboard_kacheln:
lagebild: "Markdown-Zusammenfassung mit klickbaren Zitaten"
faktencheck: "Status-Icons, Evidence-Chips, Filter"
quellenübersicht: "Aggregiert nach Quellen mit Sprach-Statistik"
timeline: "Interaktive Zeitleiste mit Bucketing, Filtern, Suche"
datenbank_tabellen:
organizations: "Multi-Tenancy Organisationen"
licenses: "Lizenzen pro Organisation (trial/annual/permanent)"
users: "Nutzer (E-Mail, Org, Rolle)"
magic_links: "Login-Tokens (10 Min. gueltig)"
portal_admins: "Admin-Zugaenge (genutzt von AegisSight-Monitor-Verwaltung)"
incidents: "Lagen/Recherchen"
articles: "Gesammelte Artikel (original + deutsche Uebersetzung)"
fact_checks: "Faktenchecks (claim, status, evidence)"
refresh_log: "Refresh-Protokoll (Token-Statistiken, Kosten)"
incident_snapshots: "Archivierte Lageberichte"
sources: "Quellen-Verwaltung (RSS-Feeds, Web-Quellen, Ausgeschlossene)"
source_health_checks: "Health-Check-Ergebnisse (Erreichbarkeit, Feed-Validitaet)"
source_suggestions: "KI-Vorschlaege (neue Quellen, Deaktivierung, URL-Fix)"
user_excluded_domains: "Per-User ausgeschlossene Domains"
notifications: "Persistente In-App-Benachrichtigungen"
incident_subscriptions: "E-Mail-Abo-Einstellungen pro User/Lage"
deployment:
service: "systemd osint-monitor.service"
restart: "sudo systemctl restart osint-monitor"
logs: "tail -f ~/AegisSight-Monitor/logs/osint-monitor.log"
status: "systemctl status osint-monitor"
- "Lagebild (Markdown + Inline-Zitate)"
- "Faktencheck (Status-Icons, Evidence, Filter)"
- "Quellenübersicht (nach Domain gruppiert)"
- "Timeline (horizontale Achse, Bucketing, Filter)"
- "Karte (Leaflet, Kategorie-Marker, Legende)"
```
## Verwandte Projekte
## Datenbank (25+ Tabellen)
```yaml
kern: "organizations, licenses, users, magic_links, portal_admins"
lagen: "incidents, articles, incident_snapshots, fact_checks, refresh_log"
quellen: "sources, source_health_checks, source_suggestions, user_excluded_domains"
geo: "article_locations"
netzwerk: "network_analyses, network_analysis_incidents, network_entities, network_entity_mentions, network_relations, network_generation_log"
system: "notifications, incident_subscriptions, feedback, token_usage_monthly"
```
## Verwandte Projekte (gleicher Server)
```yaml
verwaltungsportal:
pfad: /home/claude-dev/AegisSight-Monitor-Verwaltung
beschreibung: "Admin-Portal fuer Organisationen, Lizenzen, Nutzer"
geteilte_db: /mnt/gitea/osint-data/osint.db
url: https://monitor-verwaltung.aegis-sight.de
service: verwaltungsportal.service (Port 8892)
geteilte_db: ja
globe:
pfad: /home/claude-dev/AegisSight-Globe
url: https://globe.aegis-sight.de
service: globe.service (Port 8890)
geteilte_db: ja
netzwerkanalyse:
pfad: /home/claude-dev/AegisSight-Netzwerkanalyse
url: https://netzwerkanalyse.aegis-sight.de
service: netzwerkanalyse.service (Port 8893)
```
## Regeln
@@ -192,8 +197,151 @@ verwaltungsportal:
```yaml
regeln:
- "Jede Aenderung MUSS sofort committed und nach Gitea gepusht werden"
- "Echte Umlaute in UI-Texten verwenden, Umschreibungen in YAML/Code-Kommentaren OK"
- "Echte Umlaute in UI-Texten (ue, ae, oe, ss), keine Umschreibungen"
- "Keine Passwoerter oder Secrets in den Code committen"
- "Service nach Backend-Aenderungen neustarten: sudo systemctl restart osint-monitor"
- "Frontend-Aenderungen brauchen keinen Neustart (statische Dateien)"
- "Service nach Backend-Aenderungen: sudo systemctl restart osint-monitor"
- "Frontend-Aenderungen (HTML/JS/CSS) brauchen keinen Neustart"
- "Backup-Dateien (.bak) nicht committen, vor Push loeschen"
```
## 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
staging:
url: https://staging.monitor.aegis-sight.de
server: 46.225.141.13 (gleicher Host wie Live)
pfad: /home/claude-dev/AegisSight-Monitor-staging
branch: develop
port: 18891 (Live: 8891)
service: aegis-monitor-staging.service (systemd)
venv: /home/claude-dev/AegisSight-Monitor-staging/venv (eigenes venv)
zugriff: Magic-Link-Login an info@aegis-sight.de (Cookie 30 Tage)
datenbank:
pfad: ~/AegisSight-Monitor-staging/data/osint.db
initial: einmalige Kopie der Live-DB
drift: gewollt - Aenderungen in Staging beeinflussen Live nicht
reseed_von_live: |
sudo systemctl stop aegis-monitor-staging
cp ~/AegisSight-Monitor/data/osint.db ~/AegisSight-Monitor-staging/data/osint.db
sudo systemctl start aegis-monitor-staging
besonderheiten_env:
JWT_SECRET: eigener fuer Staging (nicht Live-JWT)
MAGIC_LINK_BASE_URL: https://staging.monitor.aegis-sight.de (sonst leitet App zu Live)
TELEGRAM_API_ID: 0 # deaktiviert - verhindert Doppel-Login mit Live
TELEGRAM_API_HASH: 0
DB-Pfad: relative aus config.py (nutzt automatisch ~/AegisSight-Monitor-staging/data/)
auth_service:
pfad: /opt/aegis-staging-auth
service: aegis-monitor-staging-auth.service
port: 127.0.0.1:8095
cookie_domain: staging.monitor.aegis-sight.de
cookie_name: aegis_monitor_staging_auth
code_quelle: identisch zum Service auf 46.225.225.49 (eigene Konfig)
```
### Workflow Staging -> Live
1. **Aenderung in develop machen** (im Staging-Verzeichnis):
```bash
cd ~/AegisSight-Monitor-staging
git checkout develop
# Aenderung
git add . && git commit -m ... && git push origin develop
```
2. **Staging aktualisieren** (aktuell manuell):
```bash
ssh claude-dev@46.225.141.13 'cd ~/AegisSight-Monitor-staging && git pull && sudo systemctl restart aegis-monitor-staging'
```
3. **In https://staging.monitor.aegis-sight.de testen**
4. **Promote zu Live**: Pull Request develop -> main in Gitea, dann:
```bash
ssh claude-dev@46.225.141.13 'cd ~/AegisSight-Monitor && git pull'
# Live laeuft als loser uvicorn-Prozess (kein systemd) - manueller Restart
# bei Backend-Aenderungen noetig
```
### Offen (noch nicht implementiert)
- Auto-Deploy bei Push auf develop (Webhook-Listener)
- Promote-UI mit Ein-Klick-Button
- Live-Monitor auf systemd umstellen (~10s Downtime einmalig)
## Auto-Deploy + Promote-UI
```yaml
auto_deploy:
listener_service:
pfad: /opt/aegis-staging-deploy
service: aegis-staging-deploy.service
port: 127.0.0.1:8096
deployments:
staging: develop -> ~/AegisSight-Monitor-staging (restartet aegis-monitor-staging)
live: main -> ~/AegisSight-Monitor (restartet aegis-monitor)
endpoints:
"POST /__deploy": staging via Gitea-Webhook (HMAC)
"POST /__deploy/live": live via Promote-UI (HMAC)
secrets: /opt/aegis-staging-deploy/.env (nicht im Repo)
gitea_webhook:
repo: AegisSight/AegisSight-Monitor
url: https://staging.monitor.aegis-sight.de/__deploy
branch_filter: develop
live_systemd:
service: aegis-monitor.service
hinweis: |
Live-Monitor laeuft seit 2026-04-26 als systemd-Service (vorher loser
uvicorn-Prozess). Manueller Restart bei Backend-Aenderungen:
sudo systemctl restart aegis-monitor
Beim Promote via UI passiert das automatisch.
promote_ui:
url: https://deploy.aegis-sight.de
laeuft_auf: 46.225.225.49 (zentral fuer alle Services)
zugriff: Magic-Link-Login an info@aegis-sight.de
funktion: |
Live- vs. Staging-Stand pro Service inkl. Liste der ausstehenden Commits.
Promote-Knopf -> Gitea-PR develop->main wird auto-gemerged + Live-Listener
pullt main + restartet aegis-monitor.
```
### Vollstaendiger Workflow (Aenderung am Monitor)
1. **Entwickeln in develop**:
```bash
cd ~/AegisSight-Monitor-staging
git checkout develop
# Aenderung
git add . && git commit -m "..." && git push origin develop
# Auto-Deploy pullt automatisch + restartet aegis-monitor-staging
```
2. **Auf https://staging.monitor.aegis-sight.de pruefen**
3. **Promoten via https://deploy.aegis-sight.de** (Klick auf Monitor-Karte)
→ Gitea merged develop→main → Listener pullt main → `systemctl restart aegis-monitor`
4. **Live-Check auf https://monitor.aegis-sight.de**

65
RELEASES.json Normale Datei
Datei anzeigen

@@ -0,0 +1,65 @@
[
{
"version": "2026-05-03T15:21Z",
"date": "2026-05-03",
"title": "Übersichtlichere Navigation in der Seitenleiste",
"items": [
"Schaltflächen in der Seitenleiste haben jetzt klarere Icons und kürzere Beschriftungen",
"Der Feedback-Button zeigt nun ein Brief-Symbol für bessere Erkennbarkeit"
]
},
{
"version": "2026-04-30T23:12Z",
"date": "2026-04-30",
"title": "Hintergrundbild-Unschärfe zuverlässiger und vollständiger",
"items": [
"Der Weichzeichner-Effekt wird jetzt stabiler angezeigt und aktualisiert sich korrekt",
"Der Header-Bereich wird nun ebenfalls korrekt mit dem Unschärfe-Effekt versehen"
]
},
{
"version": "2026-04-29T22:30Z",
"date": "2026-04-29",
"title": "Update-Meldungen folgen Hell-/Dunkelmodus, korrekte Umlaute",
"items": [
"Banner und „Was ist neu?“-Modal nutzen jetzt die Theme-Variablen und passen sich automatisch dem aktiven Hell- oder Dunkelmodus an",
"Ältere Release-Einträge mit ae/oe/ue-Schreibweise wurden auf korrekte Umlaute umgestellt"
]
},
{
"version": "2026-04-29T20:10Z",
"date": "2026-04-29",
"title": "Blur versucht zu fixen",
"items": [
"war nix..."
]
},
{
"version": "2026-04-26T21:10Z",
"date": "2026-04-26",
"title": "Update-Modal kommt jetzt auch beim ersten Besuch",
"items": [
"Beim ersten Login nach einer Aktualisierung erscheint die Was-ist-neu-Übersicht jetzt automatisch",
"Für Kunden-Onboarding: erste Highlights werden direkt sichtbar"
]
},
{
"version": "2026-04-26T20:40Z",
"date": "2026-04-26",
"title": "Updatenachricht bei Deployment",
"items": [
"Einrichtung Deployment für Updates",
"Message im Monitor bei Update"
]
},
{
"version": "5473ba3",
"date": "2026-04-26",
"title": "Update-System eingeführt",
"items": [
"Updates berühren ab jetzt nie mehr die Fälle oder Daten",
"Beim Promote landet eine 'Was ist neu'-Info hier",
"Strukturelle Trennung von Live- und Staging-Datenbank"
]
}
]

1
data
Datei anzeigen

@@ -1 +0,0 @@
/mnt/gitea/osint-data

Datei anzeigen

@@ -11,3 +11,8 @@ python-multipart
aiosmtplib
geonamescache>=2.0
telethon
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
Jinja2>=3.1
weasyprint>=68.0
python-docx>=1.2
pikepdf>=9.0

Datei anzeigen

@@ -0,0 +1,87 @@
"""Einmaliger Backfill: Laedt die 30 neuesten Artikel einer Lage und generiert
latest_developments als kompletten Rebuild (previous_developments=None).
Verwendung: python3 scripts/backfill_latest_developments.py <incident_id> [limit]
"""
import asyncio
import sqlite3
import sys
sys.path.insert(0, "src")
from agents.analyzer import AnalyzerAgent
async def backfill(incident_id: int, limit: int = 30):
c = sqlite3.connect("data/osint.db")
c.row_factory = sqlite3.Row
inc = c.execute("SELECT * FROM incidents WHERE id=?", (incident_id,)).fetchone()
if not inc:
print(f"Incident #{incident_id} nicht gefunden.")
return
title = inc["title"]
description = inc["description"] or ""
rows = c.execute(
"""SELECT id, source, source_url, language, published_at,
headline, headline_de, content_original, content_de
FROM articles WHERE incident_id=?
ORDER BY datetime(published_at) DESC LIMIT ?""",
(incident_id, limit),
).fetchall()
# Bias-Anreicherung analog zum Orchestrator (optional, Tabelle evtl. nicht vorhanden)
bias_by_name: dict[str, str] = {}
bias_by_domain: dict[str, str] = {}
try:
bias_rows = c.execute("SELECT name, domain, bias FROM source_bias").fetchall()
bias_by_name = {r["name"].lower(): r["bias"] for r in bias_rows if r["name"]}
bias_by_domain = {r["domain"].lower(): r["bias"] for r in bias_rows if r["domain"]}
except sqlite3.OperationalError:
pass
articles = []
for r in rows:
a = dict(r)
src = (a.get("source") or "").lower()
url = (a.get("source_url") or "").lower()
bias = bias_by_name.get(src)
if not bias:
for dom, b in bias_by_domain.items():
if dom and dom in url:
bias = b
break
if bias:
a["source_bias"] = bias
articles.append(a)
print(f"Backfill fuer #{incident_id} {title!r}")
print(f"Artikel als Input: {len(articles)} (neueste first)")
for a in articles[:5]:
print(f" ID {a['id']} | {a.get('published_at', '?')} | {a.get('source', '?')}")
analyzer = AnalyzerAgent()
dev_text, usage = await analyzer.generate_latest_developments(
title=title,
description=description,
new_articles=articles,
previous_developments=None,
)
print()
print("=== Neue latest_developments ===")
print(dev_text or "(leer)")
if dev_text:
c.execute("UPDATE incidents SET latest_developments=? WHERE id=?", (dev_text, incident_id))
c.commit()
print(f"\nDB aktualisiert: Incident #{incident_id}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: backfill_latest_developments.py <incident_id> [limit]")
sys.exit(1)
iid = int(sys.argv[1])
lim = int(sys.argv[2]) if len(sys.argv) > 2 else 30
asyncio.run(backfill(iid, lim))

Datei anzeigen

@@ -0,0 +1,78 @@
"""Einmal-Repair: normalisiert Umlaute in summary und latest_developments
aller aktiven Lagen deterministisch (deutsche Umschreibungs-Form -> echte Umlaute).
Idempotent: mehrfaches Ausfuehren hat keinen zusaetzlichen Effekt, wenn
bereits normalisierte Texte vorliegen.
Aufruf (auf dem Monitor-Server):
cd /home/claude-dev/AegisSight-Monitor/src
python3 ../scripts/bootstrap_umlaut_repair.py
"""
import sqlite3
import sys
import os
# Sicherstellen, dass src/ im PYTHONPATH ist, damit services/post_refresh_qc importiert werden kann
_here = os.path.dirname(os.path.abspath(__file__))
_src = os.path.abspath(os.path.join(_here, "..", "src"))
if _src not in sys.path:
sys.path.insert(0, _src)
from services.post_refresh_qc import normalize_german_umlauts # noqa: E402
DB_PATH = "/home/claude-dev/osint-data/osint.db"
def main():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
c = conn.cursor()
rows = c.execute(
"SELECT id, title, summary, latest_developments FROM incidents "
"WHERE status IN ('active', 'archived') ORDER BY id"
).fetchall()
total_summary = 0
total_dev = 0
updated = 0
for r in rows:
iid = r["id"]
title = r["title"] or ""
summary_orig = r["summary"] or ""
dev_orig = r["latest_developments"] or ""
new_summary, n_s = normalize_german_umlauts(summary_orig)
new_dev, n_d = normalize_german_umlauts(dev_orig)
if n_s == 0 and n_d == 0:
continue
c.execute(
"UPDATE incidents SET summary = ?, latest_developments = ? WHERE id = ?",
(
new_summary if n_s > 0 else summary_orig,
new_dev if n_d > 0 else dev_orig,
iid,
),
)
updated += 1
total_summary += n_s
total_dev += n_d
print(
f" Lage #{iid:>3} {title[:50]:50} "
f"summary: {n_s:>4} | latest_developments: {n_d:>3}"
)
conn.commit()
print()
print(f"Ergebnis: {updated} Lagen aktualisiert. "
f"{total_summary} Ersetzungen in summary, {total_dev} in latest_developments "
f"(gesamt {total_summary + total_dev}).")
finally:
conn.close()
if __name__ == "__main__":
main()

166
scripts/build_umlaut_dict.py Normale Datei
Datei anzeigen

@@ -0,0 +1,166 @@
"""Generiert src/services/umlaut_dict.json aus hunspell-de-de.
Aufruf (auf dem Monitor-Server):
cd /home/claude-dev/AegisSight-Monitor
python3 scripts/build_umlaut_dict.py
Voraussetzungen:
- hunspell-de-de (liefert /usr/share/hunspell/de_DE.dic + de_DE.aff)
- hunspell-tools (liefert /usr/bin/unmunch)
Ablauf:
1. unmunch rollt alle Flexionsformen aus dem hunspell-Dict aus
2. Wir filtern Woerter mit echten Umlauten (ä, ö, ü, ß)
3. Wir generieren fuer jedes Wort die Umschreibungs-Form (ae/oe/ue/ss)
4. Mehrdeutigkeits-Check: Wenn die Umschreibungs-Form selbst ein
gueltiges deutsches Wort ist (z. B. "dass" vs "daß"), skippen
5. Ausgabe als alphabetisch sortiertes JSON (diff-freundlich)
"""
import json
import locale
import os
import subprocess
import sys
DIC_PATH = "/usr/share/hunspell/de_DE.dic"
AFF_PATH = "/usr/share/hunspell/de_DE.aff"
UNMUNCH_BIN = "/usr/bin/unmunch"
OUTPUT_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "services", "umlaut_dict.json",
)
UMLAUT_MAP = (
("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"),
("Ä", "Ae"), ("Ö", "Oe"), ("Ü", "Ue"),
)
def to_ascii_form(word: str) -> str:
"""Konvertiert ein Wort mit Umlauten in seine Umschreibungs-Form."""
out = word
for uml, asc in UMLAUT_MAP:
out = out.replace(uml, asc)
return out
def has_umlaut(word: str) -> bool:
return any(ch in word for ch in "äöüßÄÖÜ")
def run_unmunch() -> set:
"""Fuehrt unmunch aus und gibt die Menge aller hunspell-Woerter zurueck."""
env = os.environ.copy()
# unmunch arbeitet mit Latin-1 als Voreinstellung; das .dic/.aff in de_DE
# ist aber UTF-8 (siehe SET UTF-8 im .aff). Wir setzen die Locale explizit.
env["LC_ALL"] = "C.UTF-8"
result = subprocess.run(
[UNMUNCH_BIN, DIC_PATH, AFF_PATH],
capture_output=True,
check=True,
env=env,
)
raw = result.stdout.decode("utf-8", errors="replace")
words = set()
for line in raw.splitlines():
w = line.strip()
if not w or w.startswith("#"):
continue
words.add(w)
return words
def build_mapping(all_words: set) -> tuple[dict, int, int]:
"""Baut das Umlaut-Ersetzungs-Mapping.
Rueckgabe: (mapping, skipped_ambiguous, words_with_umlaut)
"""
mapping = {}
skipped_ambiguous = 0
words_with_umlaut = 0
for word in all_words:
if not has_umlaut(word):
continue
words_with_umlaut += 1
ascii_form = to_ascii_form(word)
# Mehrdeutigkeits-Check: Umschreibung ist selbst ein gueltiges Wort?
if ascii_form in all_words:
skipped_ambiguous += 1
continue
# Standardfall: Mapping Umschreibung -> Umlaut-Form
mapping[ascii_form] = word
# Zusaetzlich Capitalize-Variante erzeugen (wenn anders als Original)
if ascii_form[:1].islower():
cap_ascii = ascii_form[:1].upper() + ascii_form[1:]
cap_umlaut = word[:1].upper() + word[1:]
if cap_ascii != ascii_form and cap_ascii not in all_words:
mapping[cap_ascii] = cap_umlaut
return mapping, skipped_ambiguous, words_with_umlaut
def sanity_spot_check(mapping: dict) -> None:
"""Prueft ob einige typische Testfaelle korrekt im Mapping abgebildet sind."""
expected_in = [
"oeffnung", "Oeffnung", "strasse", "Strasse", "fuer", "Fuer",
"ueber", "Ueber", "koennen", "Koennen", "muessen", "Muessen",
"moeglich", "Moeglich", "schliessen", "Schliessen",
"aussenminister", "Aussenminister", "praesident", "Praesident",
"buerger", "Buerger", "zurueck", "Zurueck", "fuehren", "Fuehren",
]
expected_not_in = [
"dass", "Dass", # moderne Form gueltig
"masse", "Masse", # Bedeutungsunterschied zu "Masse"/"Maße"
"busse", "Busse", # Bedeutungsunterschied zu "Busse"/"Buße"
]
missing = [w for w in expected_in if w not in mapping]
wrong = [w for w in expected_not_in if w in mapping]
print("Sanity-Check:")
print(f" Erwartete Eintraege gefunden: {len(expected_in) - len(missing)}/{len(expected_in)}")
if missing:
print(f" FEHLEND: {missing}")
print(f" Erwartete Ausschluesse korrekt: {len(expected_not_in) - len(wrong)}/{len(expected_not_in)}")
if wrong:
print(f" FAELSCHLICH DRIN: {wrong}")
def main():
if not os.path.exists(DIC_PATH):
print(f"FEHLER: {DIC_PATH} nicht gefunden. Paket hunspell-de-de installiert?",
file=sys.stderr)
sys.exit(1)
if not os.path.exists(UNMUNCH_BIN):
print(f"FEHLER: {UNMUNCH_BIN} nicht gefunden. Paket hunspell-tools installiert?",
file=sys.stderr)
sys.exit(1)
print(f"Lese hunspell-Dict via {UNMUNCH_BIN} ...")
all_words = run_unmunch()
print(f" {len(all_words)} hunspell-Wortformen geladen")
print("Baue Umlaut-Ersetzungs-Mapping ...")
mapping, skipped, umlaut_words = build_mapping(all_words)
print(f" {umlaut_words} Woerter mit Umlaut gefunden")
print(f" {skipped} mehrdeutige Formen uebersprungen (z.B. dass/daß)")
print(f" {len(mapping)} Eintraege im finalen Mapping")
sanity_spot_check(mapping)
print(f"\nSchreibe {OUTPUT_PATH} ...")
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
# Alphabetisch sortiert (diff-freundlich)
sorted_mapping = dict(sorted(mapping.items(), key=lambda kv: kv[0]))
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(sorted_mapping, f, ensure_ascii=False, indent=None, separators=(",", ":"))
size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024)
print(f" {size_mb:.2f} MB geschrieben")
print("Fertig.")
if __name__ == "__main__":
main()

Datei anzeigen

@@ -28,7 +28,9 @@ AUFTRAG:
STRUKTUR:
- Wenn die Meldungen thematisch klar einen einzelnen Strang behandeln: Fließtext ohne Überschriften
- Wenn verschiedene Aspekte oder Themenfelder aufkommen (z.B. Ereignis + Reaktionen + Hintergrund): Gliedere mit kurzen Markdown-Zwischenüberschriften (##)
- Die Entscheidung liegt bei dir — Überschriften nur wenn sie dem Leser helfen, verschiedene Themenstränge auseinanderzuhalten
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- Die Entscheidung liegt bei dir — Überschriften und Tabellen nur wenn sie dem Leser helfen
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Die neuesten Entwicklungen werden separat als eigene Kachel aufbereitet und dürfen im Lagebild NICHT dupliziert werden. Steige direkt mit dem Fließtext oder der ersten inhaltlichen Zwischenüberschrift ein.
REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
@@ -42,10 +44,9 @@ REGELN:
- Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...")
Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll)
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -65,8 +66,8 @@ AUFTRAG:
Erstelle ein strukturiertes Briefing auf {output_language} mit folgenden Abschnitten. Sei so ausführlich wie nötig, um alle Aspekte gründlich abzudecken.
Verwende durchgehend Inline-Quellenverweise [1], [2], [3] etc. im Text.
## ÜBERBLICK
Kurze Einordnung des Themas (2-3 Sätze)
## ZUSAMMENFASSUNG
Kompakte Übersicht als Aufzählung (4-8 Bullet Points mit "- "). Jeder Punkt fasst einen Kernaspekt des Themas in 1-2 Sätzen zusammen. Der Leser soll nach dieser Sektion das Wesentliche erfasst haben, ohne den Rest lesen zu müssen. WICHTIG: Die ZUSAMMENFASSUNG besteht AUSSCHLIESSLICH aus Bullet Points. KEIN Fliesstext vor, zwischen oder nach den Bullet Points. Detaillierte Ausführungen gehören in die anderen Sektionen (HINTERGRUND, AKTUELLE LAGE etc.).
## HINTERGRUND
Historischer Kontext, relevante Vorgeschichte
@@ -100,7 +101,6 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -130,12 +130,15 @@ AUFTRAG:
STRUKTUR:
- Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- KEIN Fettdruck (**) verwenden
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
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
- 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
- Ältere Quellen zeitlich einordnen
@@ -144,7 +147,6 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -169,8 +171,14 @@ NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
AUFTRAG:
Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie nötig. Behalte die Struktur bei:
## ÜBERBLICK
## ZUSAMMENFASSUNG
## HINTERGRUND
WICHTIG zur Sektion ZUSAMMENFASSUNG:
- Falls das bisherige Briefing eine Sektion "## ÜBERBLICK" hat, benenne sie in "## ZUSAMMENFASSUNG" um
- Die ZUSAMMENFASSUNG muss als Aufzählung formatiert sein (4-8 Bullet Points mit "- "). Jeder Punkt fasst einen Kernaspekt in 1-2 Sätzen zusammen
- Falls der bisherige ÜBERBLICK Fliesstext ist, wandle ihn in Bullet Points um
- KEIN Fliesstext vor, zwischen oder nach den Bullet Points. Die ZUSAMMENFASSUNG besteht AUSSCHLIESSLICH aus Bullet Points. Detaillierte Ausführungen gehören in die anderen Sektionen
## AKTEURE
## AKTUELLE LAGE
## EINSCHÄTZUNG
@@ -179,6 +187,7 @@ Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie n
REGELN:
- Bisherige gesicherte Fakten beibehalten
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- Falls das bisherige Briefing 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".
- Neue Erkenntnisse einarbeiten
- Veraltete Informationen aktualisieren
- 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..."
@@ -189,11 +198,72 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
LATEST_DEVELOPMENTS_PROMPT_TEMPLATE = """Du erzeugst die Kachel "Neueste Entwicklungen" für eine Live-Monitoring-Lage.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
LAGE: {title}
KONTEXT: {description}
AKTUELLES LAGEBILD (autoritative inhaltliche Grundlage):
{summary}
BELEGENDE MELDUNGEN (chronologisch absteigend, neueste zuerst — nur hieraus dürfen Zeitstempel und Quellen-Klammern stammen):
{articles_text}
AUFTRAG:
Extrahiere aus dem LAGEBILD die wichtigsten jüngsten Ereignisse und stelle sie als chronologisch absteigende Bullet-Liste dar. Für jedes Bullet wählst du eine oder mehrere belegende Meldungen aus der obigen Liste und übernimmst deren Publikationsdatum als Zeitstempel.
REGELN zur Auswahl der Bullets:
- Ziel: 4 bis 6 Bullets. Wenn das Lagebild weniger tatsächlich AKTUELLE Ereignisse hergibt, dann lieber 3 ehrliche Bullets als 6 mit veralteten. Kein Auffüllen.
- "AKTUELL" bedeutet: belegende Meldung ist spätestens ~7 Tage alt (relativ zu HEUTIGES DATUM). Ältere Ereignisse — auch wenn sie im Lagebild stehen — gehören NICHT rein. Sie sind Hintergrund, keine Neuesten Entwicklungen.
- Wenn das Lagebild ein Ereignis erwähnt, aber KEINE aktuelle belegende Meldung dafür existiert: Bullet verwerfen. Lieber weglassen als fabulieren.
- Bevorzuge Ereignisse mit hohem Neuigkeitswert und konkretem Vorfall/Aussage gegenüber allgemeinen Hintergrundkonstatierungen.
REGELN zur Formulierung:
- Jedes Bullet = EIN konkretes Ereignis oder eine konkrete Aussage, 1-2 Sätze, präzise und neutral.
- Beginne JEDES Bullet mit dem Zeitstempel der frühesten belegenden Meldung im Format "[DD.MM. HH:MM]".
- Ende JEDES Bullet mit einer Quellen-Klammer mit Pipe-getrennten Paaren "Name|URL", kommagetrennt bei mehreren Belegen: {{Reuters|https://reuters.com/..., Rybar|https://t.me/rybar/123}}. Maximal 3 Quellen pro Bullet. Bullets ohne Klammer werden verworfen.
- Sortiere die Bullets nach Zeitstempel absteigend — neueste zuerst.
- 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.
- KEINE Einleitung, KEINE Überschrift, KEINE Nachbemerkungen.
OUTPUT-FORMAT (ausschliesslich, kein Code-Fence, JEDE Zeile beginnt mit "- "):
- [DD.MM. HH:MM] Ereignistext. {{Quellenname1|URL1}}
- [DD.MM. HH:MM] Ereignistext mit mehreren Belegen. {{Quellenname1|URL1, Quellenname2|URL2}}
..."""
TOPIC_FILTER_PROMPT_TEMPLATE = """Du bist ein OSINT-Relevanzfilter. Ein vorgeschalteter Keyword-Prefilter hat diese Artikel für eine Lage durchgelassen — aber Keyword-Treffer allein reichen nicht. Artikel müssen das SPEZIFISCHE KERNTHEMA der Lage inhaltlich behandeln.
LAGE: {title}
KONTEXT: {description}
ARTIKEL-KANDIDATEN:
{articles_text}
AUFGABE:
Entscheide je Artikel, ob er thematisch zur Lage passt, und gib die laufenden Nummern der relevanten Artikel zurück.
REGELN:
- Relevant = der Artikel behandelt konkret das im Titel + Kontext beschriebene Kernthema. Zentrale Akteure, Handlungen, Aussagen oder Ereignisse des Themas müssen im Artikel erkennbar sein.
- NICHT relevant = Artikel, die nur allgemeine Begriffe aus dem Thema streifen (z.B. "Russland", "Iran", "Krieg", "Drohne"), ohne das Spezifikum der Lage zu behandeln. Allgemeine Kontext-Berichte aus der gleichen Region oder zum gleichen Großkonflikt sind NICHT automatisch relevant.
- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln.
- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt.
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer.
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
{{"relevant_ids": [1, 3, 7]}}"""
class AnalyzerAgent:
"""Analysiert und übersetzt Meldungen über Claude CLI."""
@@ -242,6 +312,7 @@ class AnalyzerAgent:
result, usage = await call_claude(prompt)
analysis = self._parse_response(result)
if analysis:
analysis = self._sanitize_sources(analysis)
logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert")
return analysis, usage
except Exception as e:
@@ -303,6 +374,8 @@ class AnalyzerAgent:
try:
result, usage = await call_claude(prompt)
analysis = self._parse_response(result)
if analysis:
analysis = self._sanitize_sources(analysis)
if analysis and self._all_previous_sources:
# Merge: alte Quellen beibehalten, neue hinzufuegen
returned_sources = analysis.get("sources", [])
@@ -325,6 +398,296 @@ class AnalyzerAgent:
logger.error(f"Inkrementelle Analyse-Fehler: {e}")
return None, None
async def filter_relevant_articles(
self,
title: str,
description: str,
articles: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Semantischer Topic-Filter (Haiku).
Nimmt die vom Keyword-Prefilter durchgelassenen Artikel und wirft diejenigen raus,
die zwar auf Keywords matchen, aber das Kernthema der Lage thematisch nicht treffen.
Fällt bei Parsing- oder API-Fehlern auf die unveränderte Liste zurück.
"""
if not articles:
return articles, None
lines = []
for i, article in enumerate(articles, 1):
headline = article.get("headline_de") or article.get("headline", "")
source = article.get("source", "Unbekannt")
content = article.get("content_de") or article.get("content_original") or ""
lines.append(f"[{i}] Quelle: {source}")
lines.append(f" Überschrift: {headline}")
if content:
lines.append(f" Inhalt: {content[:400]}")
articles_text = "\n".join(lines)
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
title=title,
description=description or "Keine weiteren Details",
articles_text=articles_text,
)
from config import CLAUDE_MODEL_FAST
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.warning(f"Topic-Filter-Fehler (behalte alle {len(articles)} Artikel): {e}")
return articles, None
parsed = self._parse_response(result)
if not parsed or not isinstance(parsed.get("relevant_ids"), list):
logger.warning(
f"Topic-Filter: keine relevant_ids geparst, behalte alle {len(articles)} Artikel"
)
return articles, usage
relevant_set = {
i for i in parsed["relevant_ids"]
if isinstance(i, int) and 1 <= i <= len(articles)
}
filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]
rejected = len(articles) - len(filtered)
if not filtered and articles:
logger.warning(
f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
"möglicherweise zu aggressiv. Behalte Original."
)
return articles, usage
logger.info(
f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
f"({rejected} verworfen)"
)
return filtered, usage
async def generate_latest_developments(
self,
title: str,
description: str,
summary: str,
recent_articles: list[dict],
previous_developments: str | None = None,
) -> tuple[str | None, ClaudeUsage | None]:
"""Generiert die Kachel 'Neueste Entwicklungen' aus dem Lagebild.
Der LLM extrahiert aus dem Summary die jüngsten Ereignisse und bindet sie an
das Publikationsdatum der belegenden Meldungen (recent_articles). Damit bleiben
die Einträge zwingend aktuell und thematisch an das Lagebild gekoppelt. Alte
Hintergrund-Erwähnungen im Lagebild erzeugen keine Bullets, weil keine aktuelle
Meldung sie belegen würde.
Gibt 4–6 Bullets (absteigend nach Zeitstempel) zurück. Bei Fehler/Parsing-Leer:
Fallback auf previous_developments (falls vorhanden), sonst None.
"""
prev = (previous_developments or "").strip() or None
if not summary or not summary.strip():
return prev, None
if not recent_articles:
return prev, None
from config import OUTPUT_LANGUAGE, CLAUDE_MODEL_FAST
today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")
# Kompakter Artikel-Block: nur die für Zeitstempel/Quellen nötigen Felder.
# Sortiert nach published_at absteigend — damit der LLM die jüngsten sofort sieht.
def _pub_sort_key(a: dict) -> str:
return a.get("published_at") or ""
sorted_articles = sorted(recent_articles, key=_pub_sort_key, reverse=True)
lines: list[str] = []
for a in sorted_articles[:60]:
headline = a.get("headline_de") or a.get("headline", "")
source = a.get("source", "Unbekannt")
url = a.get("source_url", "")
published = a.get("published_at") or "unbekannt"
bias = a.get("source_bias") or ""
line = f"- [{published}] {source}"
if bias:
line += f" ({bias})"
line += f" | {headline}"
if url:
line += f" | {url}"
lines.append(line)
articles_text = "\n".join(lines) if lines else "(keine belegenden Meldungen verfügbar)"
prompt = LATEST_DEVELOPMENTS_PROMPT_TEMPLATE.format(
title=title,
description=description or "Keine weiteren Details",
summary=summary.strip(),
articles_text=articles_text,
today=today,
output_language=OUTPUT_LANGUAGE,
)
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST, raw_text=True)
except Exception as e:
logger.error(f"Latest-Developments-Fehler: {e}")
return prev, None
bullets = self._parse_latest_developments(result, recent_articles)
if not bullets:
logger.info("Latest-Developments: keine Bullets geparst, behalte bisherigen Stand")
return prev, usage
bullets = bullets[:6]
output = "\n".join(bullets)
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
return output, usage
@staticmethod
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
Jeder Bullet MUSS mit einer Quellen-Klammer enden (geschweifte Klammern).
Items koennen drei Formen haben, werden alle zu 'Name|URL' normalisiert (URL optional):
- M<ID>: Aufloesung gegen new_articles, ergibt 'Name|URL'.
- 'Name|URL': wird uebernommen (Format aus previous_developments).
- 'Name' (ohne URL): bleibt unveraendert, wird als 'Name' gespeichert (Fallback).
Bullets ohne Klammer oder mit leerer Klammer werden verworfen.
Die URL wird direkt dem belegenden Artikel entnommen (article.source_url) — damit
ist der Klick im Frontend eindeutig auf den belegenden Post, ohne sources_json-Lookup.
"""
if not text:
return []
# Mapping id -> (name, url) aus new_articles
articles_by_id: dict[str, tuple[str, str]] = {}
if new_articles:
for a in new_articles:
aid = a.get("id")
if aid is not None:
name = (a.get("source") or "").strip()
url = (a.get("source_url") or "").strip()
if name:
articles_by_id[str(aid)] = (name, url)
bullets: list[str] = []
# Dash-Praefix + zweiter Datums-Punkt + optionales Jahr: Claude Haiku laesst diese gelegentlich weg.
bullet_re = re.compile(
r"^\s*(?:[-*•]\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$"
)
trailing_braces = re.compile(r"\{([^{}]+)\}\s*\.?\s*$")
id_item = re.compile(r"^[M#]\s*(\d+)$", re.IGNORECASE)
junk_item = re.compile(r"^(unbekannt|unknown|n/?a|keine|keine quelle|tba)$", re.IGNORECASE)
def _format_item(name: str, url: str) -> str:
"""Formatiert Name + URL zu 'Name|URL' (oder 'Name' wenn URL leer)."""
name = (name or "").strip()
url = (url or "").strip()
# Pipe im Namen ist extrem unwahrscheinlich, aber sicher ersetzen
name = name.replace("|", "/")
return f"{name}|{url}" if url else name
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
m = bullet_re.match(line)
if not m:
continue
day, month, time = m.group(1), m.group(2), m.group(3)
ts = f"{int(day):02d}.{int(month):02d}. {time}"
body = m.group(4).rstrip()
brace_match = trailing_braces.search(body)
if not brace_match:
logger.debug(f"Bullet ohne Quellen-Klammer verworfen: {line[:80]}")
continue
raw_items = [it.strip() for it in brace_match.group(1).split(",") if it.strip()]
resolved: list[str] = []
seen_keys: set[str] = set()
def _dedupe_key(name: str) -> str:
return name.strip().lower()
for it in raw_items:
if junk_item.match(it):
continue
mid = id_item.match(it)
if mid:
pair = articles_by_id.get(mid.group(1))
if pair:
name, url = pair
key = _dedupe_key(name)
if key not in seen_keys:
seen_keys.add(key)
resolved.append(_format_item(name, url))
elif "|" in it:
# bereits im Name|URL-Format
parts = it.split("|", 1)
name_p = parts[0].strip()
url_p = (parts[1] if len(parts) > 1 else "").strip()
if name_p and not junk_item.match(name_p):
key = _dedupe_key(name_p)
if key not in seen_keys:
seen_keys.add(key)
resolved.append(_format_item(name_p, url_p))
else:
key = _dedupe_key(it)
if key not in seen_keys:
seen_keys.add(key)
resolved.append(it)
if not resolved:
logger.debug(f"Bullet mit leerer/unaufloesbarer Quellen-Klammer verworfen: {line[:80]}")
continue
body_clean = body[: brace_match.start()].rstrip()
bullets.append(f"- [{ts}] {body_clean} {{{', '.join(resolved)}}}")
return bullets
def _sanitize_sources(self, analysis: dict) -> dict:
"""Entfernt Buchstaben-Suffixe aus Quellennummern (z.B. '1383a' -> 1383).
Das LLM erzeugt trotz Anweisung gelegentlich Suffix-Nummern.
Diese werden hier auf die Basisnummer normalisiert.
Duplikate werden entfernt, wobei Eintraege mit URL bevorzugt werden.
"""
sources = analysis.get("sources", [])
if not sources:
return analysis
cleaned = {}
suffix_count = 0
for s in sources:
nr = s.get("nr", "")
nr_str = str(nr)
# Prüfe auf Buchstaben-Suffix (z.B. "1383a", "1383b")
m = re.match(r"^(\d+)[a-z]$", nr_str)
if m:
base_nr = int(m.group(1))
suffix_count += 1
# Nur übernehmen wenn Basisnummer noch nicht existiert oder
# dieser Eintrag eine URL hat und der bisherige nicht
if base_nr not in cleaned:
s_copy = dict(s)
s_copy["nr"] = base_nr
cleaned[base_nr] = s_copy
elif s.get("url") and not cleaned[base_nr].get("url"):
s_copy = dict(s)
s_copy["nr"] = base_nr
cleaned[base_nr] = s_copy
else:
nr_int = int(nr) if isinstance(nr, (int, float)) or (isinstance(nr, str) and nr.isdigit()) else nr
if nr_int not in cleaned:
cleaned[nr_int] = s
elif s.get("url") and not cleaned[nr_int].get("url"):
cleaned[nr_int] = s
if suffix_count > 0:
logger.info(f"Quellen-Sanitierung: {suffix_count} Buchstaben-Suffixe entfernt")
analysis["sources"] = sorted(cleaned.values(),
key=lambda s: s.get("nr", 0) if isinstance(s.get("nr"), int) else 9999)
return analysis
def _parse_response(self, response: str) -> dict | None:
"""Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback."""
# Markdown-Code-Fences entfernen
@@ -429,5 +792,5 @@ class AnalyzerAgent:
except json.JSONDecodeError:
pass
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []}
return {"summary": summary, "sources": sources, "key_facts": []}

Datei anzeigen

@@ -1,13 +1,47 @@
"""Shared Claude CLI Client mit Usage-Tracking."""
import asyncio
import contextvars
import json
import logging
from dataclasses import dataclass
from config import CLAUDE_PATH, CLAUDE_TIMEOUT, CLAUDE_MODEL_FAST
from config import CLAUDE_PATH, CLAUDE_TIMEOUT, CLAUDE_MODEL_FAST, CLAUDE_MODEL_STANDARD
# 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)
logger = logging.getLogger("osint.claude_client")
class ClaudeCliError(RuntimeError):
"""Strukturierter Fehler aus dem Claude CLI mit Kategorie.
error_type:
- "rate_limit": Anthropic Rate-Limit oder Overload (transient, retry-tauglich)
- "auth_error": Account-Problem (Organisation hat keinen Claude-Zugang,
Token abgelaufen/ungueltig) - kein Retry sinnvoll, Admin-Aktion noetig
- "timeout": Claude CLI Timeout (transient)
- "cli_error": Sonstiger CLI-Fehler (unspezifisch, Default)
"""
def __init__(self, error_type: str, message: str):
self.error_type = error_type
self.message = message
super().__init__(f"Claude CLI [{error_type}]: {message}")
def _classify_cli_error(combined_output: str) -> str:
"""Ordnet einer Fehler-Ausgabe eine error_type-Kategorie zu."""
txt = combined_output.lower()
rate_limit_keywords = ["hit your limit", "rate limit", "resets", "rate_limit", "overloaded"]
auth_error_keywords = ["does not have access", "login again", "contact your administrator"]
if any(kw in txt for kw in rate_limit_keywords):
return "rate_limit"
if any(kw in txt for kw in auth_error_keywords):
return "auth_error"
return "cli_error"
@dataclass
class ClaudeUsage:
"""Token-Verbrauch eines einzelnen Claude CLI Aufrufs."""
@@ -38,7 +72,12 @@ class UsageAccumulator:
self.call_count += 1
async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", model: str | None = None) -> tuple[str, ClaudeUsage]:
def _sanitize_mdash(text: str) -> str:
"""Ersetzt Gedankenstriche durch Bindestriche (KI-Indikator reduzieren)."""
return text.replace("\u2014", " - ").replace("\u2013", " - ")
async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", model: str | None = None, raw_text: bool = False, timeout: float | None = None) -> tuple[str, ClaudeUsage]:
"""Ruft Claude CLI auf. Gibt (result_text, usage) zurück.
Prompt wird via stdin uebergeben um OS ARG_MAX Limits zu vermeiden.
@@ -46,20 +85,22 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
Args:
prompt: Der Prompt fuer Claude
tools: Kommagetrennte erlaubte Tools (None = keine Tools, --max-turns 1)
model: Optionales Modell (z.B. CLAUDE_MODEL_FAST fuer Haiku). None = CLI-Default (Opus).
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).
"""
cmd = [CLAUDE_PATH, "-p", "-", "--output-format", "json"]
if model:
cmd.extend(["--model", model])
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]
if tools:
cmd.extend(["--allowedTools", tools])
else:
cmd.extend(["--max-turns", "1", "--allowedTools", ""])
cmd.extend(["--append-system-prompt",
"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 }."])
if not raw_text:
cmd.extend(["--append-system-prompt",
"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 }."])
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
@@ -72,30 +113,59 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
},
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")), timeout=CLAUDE_TIMEOUT
)
cancel_event = _cancel_event_var.get(None)
if cancel_event:
# Cancel-aware: Monitor cancel_event while process runs
communicate_task = asyncio.create_task(
process.communicate(input=prompt.encode("utf-8"))
)
cancel_wait_task = asyncio.create_task(cancel_event.wait())
timeout_task = asyncio.create_task(asyncio.sleep(effective_timeout))
done, pending = await asyncio.wait(
[communicate_task, cancel_wait_task, timeout_task],
return_when=asyncio.FIRST_COMPLETED,
)
for p in pending:
p.cancel()
if communicate_task in done:
stdout, stderr = communicate_task.result()
elif cancel_wait_task in done:
process.kill()
await process.wait()
raise asyncio.CancelledError("Cancel angefordert")
else:
process.kill()
await process.wait()
raise TimeoutError(f"Claude CLI Timeout nach {effective_timeout}s")
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")), timeout=effective_timeout
)
except asyncio.TimeoutError:
process.kill()
raise TimeoutError(f"Claude CLI Timeout nach {CLAUDE_TIMEOUT}s")
raise TimeoutError(f"Claude CLI Timeout nach {effective_timeout}s")
if process.returncode != 0:
error_msg = stderr.decode("utf-8", errors="replace").strip()
stdout_msg = stdout.decode("utf-8", errors="replace").strip()
# Rate-Limit-Fehler kommen als JSON auf stdout, nicht auf stderr
error_type = "cli_error"
rate_limit_keywords = ["hit your limit", "rate limit", "resets", "rate_limit", "overloaded"]
combined_output = f"{error_msg} {stdout_msg}".lower()
if any(kw in combined_output for kw in rate_limit_keywords):
error_type = "rate_limit"
# Rate-Limit/Auth-Fehler kommen teils als JSON auf stdout, nicht auf stderr
combined_output = f"{error_msg} {stdout_msg}"
error_type = _classify_cli_error(combined_output)
if error_type == "rate_limit":
logger.warning(f"Claude CLI Rate-Limit (Exit {process.returncode}): {stdout_msg or error_msg}")
elif error_type == "auth_error":
logger.error(f"Claude CLI Auth-Fehler (Exit {process.returncode}): {stdout_msg or error_msg}")
else:
logger.error(f"Claude CLI Fehler (Exit {process.returncode}): {error_msg}")
if stdout_msg:
logger.error(f"Claude CLI stdout bei Fehler: {stdout_msg[:500]}")
raise RuntimeError(f"Claude CLI Fehler [{error_type}]: {stdout_msg or error_msg}")
raise ClaudeCliError(error_type, stdout_msg or error_msg)
raw = stdout.decode("utf-8", errors="replace").strip()
usage = ClaudeUsage()
@@ -103,6 +173,19 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
try:
data = json.loads(raw)
# CLI kann returncode=0 liefern und trotzdem is_error=true setzen
# (z.B. "Your organization does not have access to Claude")
if data.get("is_error"):
error_text = str(data.get("result", ""))
error_type = _classify_cli_error(error_text)
if error_type == "rate_limit":
logger.warning(f"Claude CLI Rate-Limit (is_error): {error_text}")
elif error_type == "auth_error":
logger.error(f"Claude CLI Auth-Fehler (is_error): {error_text}")
else:
logger.error(f"Claude CLI Fehler (is_error): {error_text}")
raise ClaudeCliError(error_type, error_text)
result_text = data.get("result", raw)
u = data.get("usage", {})
usage = ClaudeUsage(
@@ -122,4 +205,5 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
except json.JSONDecodeError:
logger.warning("Claude CLI Antwort kein gültiges JSON, nutze raw output")
result_text = _sanitize_mdash(result_text)
return result_text, usage

Datei anzeigen

@@ -1,4 +1,4 @@
"""Netzwerkanalyse: Entity-Extraktion (Haiku) + Beziehungsanalyse (Batched)."""
"""Netzwerkanalyse: Entity-Extraktion (Sonnet) + Beziehungsanalyse (Batched) mit Artikel-Deduplizierung."""
import asyncio
import hashlib
import json
@@ -9,7 +9,7 @@ from datetime import datetime
from typing import Optional
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
from config import CLAUDE_MODEL_FAST, TIMEZONE
from config import CLAUDE_MODEL_FAST, CLAUDE_MODEL_MEDIUM, TIMEZONE
logger = logging.getLogger("osint.entity_extractor")
@@ -194,6 +194,114 @@ def _compute_data_hash(article_ids, factcheck_ids, article_ts, factcheck_ts) ->
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Artikel-Deduplizierung
# ---------------------------------------------------------------------------
def _normalize_headline(headline: str) -> str:
"""Normalisiert eine Headline fuer Vergleiche."""
h = headline.lower().strip()
h = re.sub(r"[^a-z0-9\s]", "", h)
h = re.sub(r"\s+", " ", h).strip()
return h
def _headline_tokens(headline: str) -> set[str]:
"""Extrahiert bedeutungstragende Tokens aus einer Headline."""
tokens = set()
for word in _normalize_headline(headline).split():
if len(word) >= 3 and word not in _STOP_WORDS:
tokens.add(word)
return tokens
def _jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard-Aehnlichkeit zweier Mengen."""
if not set_a or not set_b:
return 0.0
intersection = set_a & set_b
union = set_a | set_b
return len(intersection) / len(union) if union else 0.0
def _content_fingerprint(text: str) -> str:
"""Kurzer Hash des Textinhalts fuer Near-Duplicate-Erkennung."""
normalized = re.sub(r"\s+", " ", text.lower().strip())[:500]
return hashlib.md5(normalized.encode("utf-8")).hexdigest()
def _deduplicate_articles(articles: list[dict], factchecks: list[dict]) -> tuple[list[dict], list[dict]]:
"""Entfernt redundante Artikel basierend auf Headline-Similarity und Content-Hash.
Behaelt pro Duplikat-Gruppe den Artikel mit dem laengsten Content.
Faktenchecks werden nicht dedupliziert (sind bereits einzigartig).
Returns:
Tuple von (deduplizierte_artikel, factchecks_unveraendert)
"""
if len(articles) <= 50:
return articles, factchecks
logger.info(f"Artikel-Dedup: {len(articles)} Artikel pruefen")
# Phase A: Exakte Content-Fingerprint-Dedup
seen_fingerprints: dict[str, int] = {}
for i, art in enumerate(articles):
content = art.get("content_de") or art.get("content_original") or ""
headline = art.get("headline_de") or art.get("headline") or ""
if not content and not headline:
continue
fp = _content_fingerprint(headline + " " + content)
if fp in seen_fingerprints:
existing_idx = seen_fingerprints[fp]
existing_content = articles[existing_idx].get("content_de") or articles[existing_idx].get("content_original") or ""
if len(content) > len(existing_content):
seen_fingerprints[fp] = i
else:
seen_fingerprints[fp] = i
after_fp = list(seen_fingerprints.values())
fp_removed = len(articles) - len(after_fp)
# Phase B: Headline-Similarity-Dedup (Jaccard >= 0.7)
remaining = [articles[i] for i in sorted(after_fp)]
token_sets = []
for art in remaining:
headline = art.get("headline_de") or art.get("headline") or ""
token_sets.append(_headline_tokens(headline))
keep_mask = [True] * len(remaining)
for i in range(len(remaining)):
if not keep_mask[i]:
continue
for j in range(i + 1, len(remaining)):
if not keep_mask[j]:
continue
if _jaccard_similarity(token_sets[i], token_sets[j]) >= 0.7:
content_i = remaining[i].get("content_de") or remaining[i].get("content_original") or ""
content_j = remaining[j].get("content_de") or remaining[j].get("content_original") or ""
if len(content_j) > len(content_i):
keep_mask[i] = False
break
else:
keep_mask[j] = False
deduped = [art for art, keep in zip(remaining, keep_mask) if keep]
headline_removed = len(remaining) - len(deduped)
logger.info(
f"Artikel-Dedup abgeschlossen: {len(articles)} -> {len(deduped)} "
f"({fp_removed} Content-Duplikate, {headline_removed} Headline-Duplikate entfernt)"
)
return deduped, factchecks
# ---------------------------------------------------------------------------
# Entity-Merge Helper
# ---------------------------------------------------------------------------
@@ -279,8 +387,8 @@ async def _phase1_extract_entities(
headline = art.get("headline_de") or art.get("headline") or ""
content = art.get("content_de") or art.get("content_original") or ""
source = art.get("source") or ""
if len(content) > 2000:
content = content[:2000] + "..."
if len(content) > 800:
content = content[:800] + "..."
all_texts.append(f"[{source}] {headline}\n{content}")
for fc in factchecks:
@@ -293,7 +401,7 @@ async def _phase1_extract_entities(
logger.warning(f"Analyse {analysis_id}: Keine Texte vorhanden")
return []
batch_size = 30
batch_size = 50
batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)]
logger.info(f"{len(all_texts)} Texte in {len(batches)} Batches")
@@ -304,10 +412,10 @@ async def _phase1_extract_entities(
prompt = ENTITY_EXTRACTION_PROMPT.format(articles_text=articles_text)
try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_MEDIUM)
usage_acc.add(usage)
except Exception as e:
logger.error(f"Haiku Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}")
logger.error(f"Sonnet Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}")
continue
parsed = _parse_json_response(result_text)
@@ -500,8 +608,8 @@ async def _phase2_analyze_relationships(
headline = art.get("headline_de") or art.get("headline") or ""
content = art.get("content_de") or art.get("content_original") or ""
source = art.get("source") or ""
if len(content) > 2000:
content = content[:2000] + "..."
if len(content) > 800:
content = content[:800] + "..."
all_texts.append(f"[{source}] {headline}\n{content}")
for fc in factchecks:
@@ -514,7 +622,7 @@ async def _phase2_analyze_relationships(
return []
# --- Stufe A: Per-Batch Beziehungsextraktion ---
batch_size = 30
batch_size = 50
batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)]
logger.info(f"Stufe A: {len(batches)} Batches für Beziehungsextraktion")
@@ -545,7 +653,7 @@ async def _phase2_analyze_relationships(
)
try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_MEDIUM)
usage_acc.add(usage)
except Exception as e:
logger.error(f"Relationship Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}")
@@ -1067,6 +1175,9 @@ async def extract_and_relate_entities(analysis_id: int, tenant_id: int, ws_manag
logger.info(f"Analyse {analysis_id}: {len(articles)} Artikel, "
f"{len(factchecks)} Faktenchecks aus {len(incident_ids)} Lagen")
# Artikel-Deduplizierung vor KI-Pipeline
articles, factchecks = _deduplicate_articles(articles, factchecks)
# Phase 1: Entity-Extraktion
if not await _check_analysis_exists(db, analysis_id):
return

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -10,6 +10,7 @@ logger = logging.getLogger("osint.factchecker")
FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
VORFALL: {title}
@@ -48,6 +49,7 @@ Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""
RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
THEMA: {title}
@@ -89,6 +91,7 @@ Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""
INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
VORFALL: {title}
@@ -130,6 +133,7 @@ Antworte NUR mit dem JSON-Array."""
INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
THEMA: {title}
@@ -215,6 +219,7 @@ Antworte AUSSCHLIESSLICH als JSON:
VERIFY_GROUP_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
THEMA DIESER GRUPPE: {theme}
@@ -260,6 +265,7 @@ Für NEUE Fakten setze id auf null."""
VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
THEMA DIESER GRUPPE: {theme}
@@ -449,7 +455,11 @@ class FactCheckerAgent:
status = fc.get("status", "developing")
claim = fc.get("claim", "")
sources = fc.get("sources_count", 0)
lines.append(f"- [{status}] ({sources} Quellen) {claim}")
evidence = (fc.get("evidence") or "")[:200]
line = f"- [{status}] ({sources} Quellen) {claim}"
if evidence:
line += f"\n Evidenz: {evidence}"
lines.append(line)
return "\n".join(lines)
async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[list[dict], ClaudeUsage | None]:

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -7,14 +7,69 @@ from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.researcher")
class ResearcherParseError(Exception):
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
def _truncate_for_log(text: str, limit: int = 600) -> str:
"""Kürzt eine Claude-Antwort für Logs, damit ein Sample sichtbar ist."""
if not text:
return ""
snippet = text.strip().replace("\n", "\\n")
if len(snippet) > limit:
snippet = snippet[:limit] + "..."
return snippet
def _extract_json_array(text: str):
"""Findet das erste vollständige JSON-Array im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
if not text:
return None
decoder = json.JSONDecoder()
idx = 0
while True:
bracket = text.find("[", idx)
if bracket == -1:
return None
try:
obj, _ = decoder.raw_decode(text, bracket)
except json.JSONDecodeError:
idx = bracket + 1
continue
if isinstance(obj, list):
return obj
idx = bracket + 1
def _extract_json_object(text: str):
"""Findet das erste vollständige JSON-Objekt im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
if not text:
return None
decoder = json.JSONDecoder()
idx = 0
while True:
brace = text.find("{", idx)
if brace == -1:
return None
try:
obj, _ = decoder.raw_decode(text, brace)
except json.JSONDecodeError:
idx = brace + 1
continue
if isinstance(obj, dict):
return obj
idx = brace + 1
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
Titel: {title}
Kontext: {description}
{existing_context}
{existing_context}{preferred_sources_block}
REGELN:
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
@@ -39,12 +94,13 @@ Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""
DEEP_RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Tiefenrecherche-Agent für ein Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
Titel: {title}
Kontext: {description}
{existing_context}
{existing_context}{preferred_sources_block}
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
PHASE 1 — BREITE ERFASSUNG:
@@ -156,6 +212,24 @@ Antwort NUR als JSON-Array:
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
LAGE: {title}
KONTEXT: {description}
WEB-QUELLEN:
{source_list}
REGELN:
- Waehle nur Quellen, die thematisch tatsaechlich zur Lage passen
- Lieber leere Liste zurueckgeben als pauschal alle aufnehmen
- Behoerden- und institutionelle Quellen sind oft hochwertig, aber nur wenn das Thema passt
- Petitions-Plattformen z.B. nur bei Lagen zu Buergerinitiativen, Gesetzen, oeffentlichem Druck
- Bei reinen Kriegs-/Konflikt-/Tagesnachrichten meistens leere Liste
Antworte NUR mit einem JSON-Array der Quellen-Nummern, z.B. [1, 3] oder []."""
TELEGRAM_CHANNEL_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von Telegram-Kanaelen diejenigen aus, die fuer die Lage relevant sein koennten.
LAGE: {title}
@@ -209,30 +283,28 @@ class ResearcherAgent:
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
# Neues Format: JSON-Objekt mit "feeds" und "keywords"
keywords = None
indices = None
# Versuche JSON-Objekt zu parsen
obj_match = re.search(r'\{[^{}]*"feeds"\s*:\s*\[[\d\s,]+\][^{}]*\}', result, re.DOTALL)
if obj_match:
try:
obj = json.loads(obj_match.group())
indices = obj.get("feeds", [])
raw_keywords = obj.get("keywords", [])
if isinstance(raw_keywords, list) and raw_keywords:
keywords = [str(k).lower().strip() for k in raw_keywords if k]
logger.info(f"Feed-Selektion Keywords: {keywords}")
except (json.JSONDecodeError, ValueError):
pass
# Neues Format: {"feeds": [...], "keywords": [...]}
obj = _extract_json_object(result)
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
indices = obj["feeds"]
raw_keywords = obj.get("keywords", [])
if isinstance(raw_keywords, list) and raw_keywords:
keywords = [str(k).lower().strip() for k in raw_keywords if k]
logger.info(f"Feed-Selektion Keywords: {keywords}")
# Fallback: altes Array-Format
# Fallback: nacktes Array
if indices is None:
arr_match = re.search(r'\[[\d\s,]+\]', result)
if not arr_match:
logger.warning("Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds")
arr = _extract_json_array(result)
if not isinstance(arr, list):
logger.warning(
"Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds. Sample: %s",
_truncate_for_log(result),
)
return feeds_metadata, None, usage
indices = json.loads(arr_match.group())
indices = arr
selected = []
for idx in indices:
@@ -273,19 +345,12 @@ class ResearcherAgent:
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
parsed = None
try:
parsed = json.loads(result)
except json.JSONDecodeError:
match = re.search(r'\[.*\]', result, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
except json.JSONDecodeError:
pass
if not parsed or not isinstance(parsed, list):
logger.warning("Keyword-Extraktion: Kein gueltiges JSON erhalten")
parsed = _extract_json_array(result)
if not isinstance(parsed, list):
logger.warning(
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
_truncate_for_log(result),
)
return None, usage
# Flache Liste: alle DE + EN Begriffe
@@ -308,9 +373,35 @@ class ResearcherAgent:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
return None, None
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None) -> tuple[list[dict], ClaudeUsage | None]:
"""Sucht nach Informationen zu einem Vorfall."""
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
"""Sucht nach Informationen zu einem Vorfall.
Returns:
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
"""
from config import OUTPUT_LANGUAGE
# Bevorzugte Web-Quellen als Prompt-Block (optional)
preferred_sources_block = ""
if preferred_sources:
ps_lines = []
for s in preferred_sources:
domain = s.get("domain", "")
name = s.get("name", domain) or domain
if not domain:
continue
ps_lines.append(f"- {domain} ({name})")
if ps_lines:
preferred_sources_block = (
"\nEINGETRAGENE WEB-QUELLEN (vom Betreiber als seriös markiert):\n"
+ "\n".join(ps_lines) + "\n"
"EMPFEHLUNG: Wenn diese Domains thematisch zur Lage passen, suche dort gezielt "
"mit \"site:domain [Suchbegriff]\". Sie sind vertrauenswuerdig eingetragen, ersetzen "
"aber nicht deine sonstige Recherche.\n"
)
if incident_type == "research":
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
# Bestehende Artikel als Kontext für den Prompt aufbereiten
@@ -330,6 +421,7 @@ class ResearcherAgent:
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
title=title, description=description, language_instruction=lang_instruction,
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
preferred_sources_block=preferred_sources_block,
)
else:
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
@@ -348,11 +440,18 @@ class ResearcherAgent:
prompt = RESEARCH_PROMPT_TEMPLATE.format(
title=title, description=description, language_instruction=lang_instruction,
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
preferred_sources_block=preferred_sources_block,
)
try:
result, usage = await call_claude(prompt)
articles = self._parse_response(result)
try:
articles = self._parse_response(result)
except ResearcherParseError as parse_err:
# Claude hat geantwortet, aber kein verwertbares JSON dabei.
# Usage trotzdem zurueckgeben, damit Credits korrekt verbucht werden.
logger.warning("Claude-Recherche: %s", parse_err)
return [], usage, True
# Ausgeschlossene Quellen dynamisch aus DB laden
excluded_sources = await self._get_excluded_sources(user_id=user_id)
@@ -374,13 +473,13 @@ class ResearcherAgent:
filtered.append(article)
logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
return filtered, usage
return filtered, usage, False
except TimeoutError:
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
except Exception as e:
logger.error(f"Recherche-Fehler: {e}")
return [], None
return [], None, False
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
"""Laedt ausgeschlossene Quellen (global + per-User)."""
@@ -403,56 +502,118 @@ class ResearcherAgent:
return list(EXCLUDED_SOURCES)
def _parse_response(self, response: str) -> list[dict]:
"""Parst die Claude-Antwort als JSON-Array."""
# Versuche JSON direkt zu parsen
"""Parst die Claude-Antwort als JSON-Array.
Wirft ResearcherParseError, wenn die Antwort nicht-leer ist, sich aber
kein JSON extrahieren laesst. Eine echte leere Liste (z.B. wenn Claude
wirklich keine Treffer hat) wird als [] zurueckgegeben.
"""
text = (response or "").strip()
if not text:
return []
# 1) Direkt parsen (Antwort ist bereits sauberes JSON)
try:
data = json.loads(response)
data = json.loads(text)
if isinstance(data, list):
return data
if isinstance(data, dict) and "articles" in data:
if isinstance(data, dict) and isinstance(data.get("articles"), list):
return data["articles"]
except json.JSONDecodeError:
pass
# JSON-Code-Block extrahieren
code_pat = r'`{3}(?:json)?\s*\n?(\[.*?\])\s*`{3}'
code_match = re.search(code_pat, response, re.DOTALL)
if code_match:
# 2) JSON-Array irgendwo im Text (Markdown-Fence oder Vor-/Nachtext)
arr = _extract_json_array(text)
if isinstance(arr, list):
return arr
# 3) JSON-Objekt mit "articles"-Key
obj = _extract_json_object(text)
if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
return obj["articles"]
# 4) Recovery: einzelne Headline-Objekte aus Fliesstext
recovered = []
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):
try:
data = json.loads(code_match.group(1))
if isinstance(data, list):
return data
parsed = json.loads(obj_str)
except json.JSONDecodeError:
pass
continue
if isinstance(parsed, dict) and "headline" in parsed:
recovered.append(parsed)
if recovered:
logger.info("JSON-Recovery: %d Artikel aus Einzelobjekten extrahiert", len(recovered))
return recovered
# Versuche JSON aus der Antwort zu extrahieren (zwischen [ und ])
arr_pat = r'\[\s*\{.*\}\s*\]'
match = re.search(arr_pat, response, re.DOTALL)
if match:
try:
data = json.loads(match.group())
if isinstance(data, list):
return data
except json.JSONDecodeError:
pass
# Parse fehlgeschlagen — Claude hat geantwortet, aber kein verwertbares JSON dabei.
# Sample loggen, damit der Fehler debuggbar ist, und Aufrufer signalisieren.
logger.warning(
"Konnte Claude-Antwort nicht als JSON parsen (Laenge: %d). Sample: %s",
len(text),
_truncate_for_log(text),
)
raise ResearcherParseError(f"Claude-Antwort enthielt kein verwertbares JSON (Laenge: {len(text)})")
# Letzter Versuch: einzelne JSON-Objekte mit headline
objects = re.findall(r'\{[^{}]*"headline"[^{}]*\}', response)
if objects:
results = []
for obj_str in objects:
try:
obj = json.loads(obj_str)
if "headline" in obj:
results.append(obj)
except json.JSONDecodeError:
continue
if results:
logger.info(f"JSON-Recovery: {len(results)} Artikel aus Einzelobjekten extrahiert")
return results
async def select_relevant_web_sources(
self,
title: str,
description: str,
web_sources: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Laesst Claude die thematisch passenden Web-Quellen auswaehlen (Haiku).
logger.warning(f"Konnte Claude-Antwort nicht als JSON parsen (Laenge: {len(response)})")
return []
Returns:
(ausgewaehlte Quellen, usage). Bei Fehler: ([], None).
Leere Auswahl ist explizit erlaubt — keine Quelle wird zwangsweise aufgenommen.
"""
if not web_sources:
return [], None
# Bei sehr wenigen Quellen lohnt der Selektions-Call kaum — alle weiterreichen.
if len(web_sources) <= 3:
logger.info("Web-Source-Selektion: Nur %d Quellen, alle uebernehmen", len(web_sources))
return list(web_sources), None
lines = []
for i, src in enumerate(web_sources, 1):
cat = src.get("category", "sonstige")
notes = (src.get("notes") or "")[:80]
domain = src.get("domain", "")
line = f"{i}. {src.get('name', domain)} ({domain}) [{cat}]"
if notes:
line += f" - {notes}"
lines.append(line)
prompt = WEB_SOURCE_SELECTION_PROMPT.format(
title=title,
description=description or "Keine weitere Beschreibung",
source_list="\n".join(lines),
)
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
indices = _extract_json_array(result)
if not isinstance(indices, list):
logger.warning(
"Web-Source-Selektion: Kein JSON in Antwort, ignoriere Quellen. Sample: %s",
_truncate_for_log(result),
)
return [], usage
selected = []
for idx in indices:
if isinstance(idx, int) and 1 <= idx <= len(web_sources):
selected.append(web_sources[idx - 1])
logger.info(
"Web-Source-Selektion: %d von %d ausgewaehlt%s",
len(selected), len(web_sources),
f" ({', '.join(s.get('domain', '') for s in selected)})" if selected else "",
)
return selected, usage
except Exception as e:
logger.warning("Web-Source-Selektion fehlgeschlagen (%s)", e)
return [], None
async def select_relevant_telegram_channels(
self,
@@ -486,12 +647,14 @@ class ResearcherAgent:
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
arr_match = re.search(r'\[[\d\s,]+\]', result)
if not arr_match:
logger.warning("Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele")
indices = _extract_json_array(result)
if not isinstance(indices, list):
logger.warning(
"Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele. Sample: %s",
_truncate_for_log(result),
)
return channels_metadata, usage
indices = json.loads(arr_match.group())
selected = []
for idx in indices:
if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):

254
src/agents/translator.py Normale Datei
Datei anzeigen

@@ -0,0 +1,254 @@
"""Translator-Agent: uebersetzt fremdsprachige Artikel ins Deutsche.
Eigener Agent (separat vom Analyzer), damit Token-Limits nicht zwischen
Lagebild und Uebersetzung konkurrieren. Nutzt CLAUDE_MODEL_FAST (Haiku) in
Batches.
Aufgerufen vom Orchestrator nach analyzer.analyze() und vor post_refresh_qc.
Backfill-Skript nutzt dieselbe Funktion fuer rueckwirkendes Auffuellen.
"""
import json
import logging
import re
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
from config import CLAUDE_MODEL_FAST, TRANSLATOR_ENABLED
logger = logging.getLogger("osint.translator")
# Pro Batch nicht mehr als so viele Artikel an Claude geben.
# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
DEFAULT_BATCH_SIZE = 5
# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
CONTENT_INPUT_MAX = 1200
# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
CONTENT_OUTPUT_MAX = 1000
def _extract_complete_objects(text: str) -> list[dict]:
"""Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
"""
results = []
depth = 0
start = -1
in_string = False
escape = False
for i, ch in enumerate(text):
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0 and start >= 0:
obj_text = text[start:i + 1]
try:
obj = json.loads(obj_text)
if isinstance(obj, dict):
results.append(obj)
except json.JSONDecodeError:
pass
start = -1
return results
def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
"""Bauen den Translation-Prompt fuer eine Batch."""
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
items = []
for a in articles:
items.append({
"id": a["id"],
"headline": a.get("headline", "") or "",
"content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
"source_lang": a.get("language", "en"),
})
return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
Uebersetze die folgenden Artikel nach {lang_label}.
WICHTIG:
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
- Headline kurz und buendig wie im Original.
- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
kopiere headline und content unveraendert.
Antworte AUSSCHLIESSLICH mit einem flachen JSON-Array (kein Wrapper-Objekt!).
Format genau so:
[
{{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
{{"id": 2, "headline_de": "...", "content_de": "..."}}
]
NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
Nur das Array, ohne Einleitung, ohne Erklaerung.
ARTIKEL:
{json.dumps(items, ensure_ascii=False, indent=2)}
"""
def _parse_response(text: str) -> list[dict]:
"""Robustes JSON-Array-Parsing.
Handhabt:
- reines JSON
- JSON in Markdown-Codefence ```json ... ```
- abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
"""
text = text.strip()
# Markdown-Codefence entfernen
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
text = text.strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
# Erst Array versuchen
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
# Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
data = _extract_complete_objects(text)
else:
data = _extract_complete_objects(text)
# Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
if isinstance(data, dict):
for key in ("translations", "items", "results", "data"):
if isinstance(data.get(key), list):
data = data[key]
break
else:
# Einzelnes Objekt? Dann als Liste mit einem Element behandeln
if "id" in data:
data = [data]
else:
raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
if not isinstance(data, list):
raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
cleaned = []
for item in data:
if not isinstance(item, dict):
continue
aid = item.get("id")
if not isinstance(aid, int):
try:
aid = int(aid)
except (TypeError, ValueError):
continue
cleaned.append({
"id": aid,
"headline_de": (item.get("headline_de") or "").strip() or None,
"content_de": (item.get("content_de") or "").strip() or None,
})
return cleaned
async def translate_articles_batch(
articles: list[dict],
output_lang: str = "de",
) -> tuple[list[dict], ClaudeUsage]:
"""Uebersetzt eine Batch von Artikeln.
Erwartet articles als Liste von Dicts mit den Feldern id, headline,
content_original, language.
Rueckgabe: (uebersetzte_artikel, usage)
Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
Caller kann entscheiden, ob retry oder skip.
"""
if not articles:
return [], ClaudeUsage()
prompt = _build_prompt(articles, output_lang)
try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
return [], ClaudeUsage()
try:
translations = _parse_response(result_text)
except Exception as e:
logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
return [], usage
# Validierung: nur Translations zurueckgeben, deren id wirklich
# in der angefragten Batch war
requested_ids = {a["id"] for a in articles}
valid = [t for t in translations if t["id"] in requested_ids]
if len(valid) != len(translations):
logger.warning(
"Translator: %d von %d Translations referenzieren unbekannte IDs",
len(translations) - len(valid), len(translations),
)
return valid, usage
async def translate_articles(
articles: list[dict],
output_lang: str = "de",
batch_size: int = DEFAULT_BATCH_SIZE,
usage_accumulator: UsageAccumulator | None = None,
) -> list[dict]:
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
wird er uebersprungen (anderer Batches laufen weiter).
"""
if not articles:
return []
if not TRANSLATOR_ENABLED:
logger.info(
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
len(articles),
)
return []
all_translations = []
for i in range(0, len(articles), batch_size):
batch = articles[i : i + batch_size]
translations, usage = await translate_articles_batch(batch, output_lang)
if usage_accumulator is not None:
usage_accumulator.add(usage)
all_translations.extend(translations)
logger.info(
"Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
(i // batch_size) + 1,
(len(articles) + batch_size - 1) // batch_size,
len(translations), len(batch),
usage.cost_usd,
)
return all_translations

Datei anzeigen

@@ -1,13 +1,12 @@
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
import secrets
import string
from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS, TIMEZONE
from config import get_jwt_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS, TIMEZONE
security = HTTPBearer()
security = HTTPBearer(auto_error=False)
JWT_ISSUER = "intelsight-osint"
@@ -21,6 +20,7 @@ def create_token(
role: str = "member",
tenant_id: int = None,
org_slug: str = None,
is_global_admin: bool = False,
) -> str:
"""JWT-Token erstellen mit Tenant-Kontext."""
now = datetime.now(TIMEZONE)
@@ -32,12 +32,13 @@ def create_token(
"role": role,
"tenant_id": tenant_id,
"org_slug": org_slug,
"is_global_admin": is_global_admin,
"iss": JWT_ISSUER,
"aud": JWT_AUDIENCE,
"iat": now,
"exp": expire,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
@@ -45,7 +46,7 @@ def decode_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
JWT_SECRET,
get_jwt_secret(),
algorithms=[JWT_ALGORITHM],
issuer=JWT_ISSUER,
audience=JWT_AUDIENCE,
@@ -62,6 +63,11 @@ async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""FastAPI Dependency: Aktuellen Nutzer aus Token extrahieren."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
)
payload = decode_token(credentials.credentials)
return {
"id": int(payload["sub"]),
@@ -70,6 +76,7 @@ async def get_current_user(
"role": payload.get("role", "member"),
"tenant_id": payload.get("tenant_id"),
"org_slug": payload.get("org_slug"),
"is_global_admin": payload.get("is_global_admin", False),
}
@@ -77,7 +84,3 @@ def generate_magic_token() -> str:
"""Generiert einen 64-Zeichen URL-safe Token."""
return secrets.token_urlsafe(48)
def generate_magic_code() -> str:
"""Generiert einen 6-stelligen numerischen Code."""
return ''.join(secrets.choice(string.digits) for _ in range(6))

Datei anzeigen

@@ -10,12 +10,19 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
LOG_DIR = os.path.join(BASE_DIR, "logs")
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
DB_PATH = os.path.join(DATA_DIR, "osint.db")
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "osint.db")
# JWT
JWT_SECRET = os.environ.get("JWT_SECRET")
if not JWT_SECRET:
raise RuntimeError("JWT_SECRET Umgebungsvariable muss gesetzt sein")
_JWT_SECRET = os.environ.get("JWT_SECRET", "")
def get_jwt_secret() -> str:
"""Gibt JWT_SECRET zurück. Wirft RuntimeError wenn nicht gesetzt."""
if not _JWT_SECRET:
raise RuntimeError("JWT_SECRET Umgebungsvariable muss gesetzt sein")
return _JWT_SECRET
# Rückwärtskompatibel für direkte Imports
JWT_SECRET = _JWT_SECRET
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
@@ -24,6 +31,8 @@ CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/usr/bin/claude")
CLAUDE_TIMEOUT = 1800 # Sekunden (30 Min - Lage-Updates mit vielen Artikeln brauchen mehr Zeit)
# Claude Modelle
CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-Selektion)
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
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
OUTPUT_LANGUAGE = "Deutsch"
@@ -32,6 +41,10 @@ OUTPUT_LANGUAGE = "Deutsch"
# In Kundenversion auf False setzen oder Env-Variable entfernen
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
# Feature-Flag: Translator-Agent (Haiku) komplett deaktivieren.
# False = keine Uebersetzungen mehr, fremdsprachige Artikel bleiben unuebersetzt.
TRANSLATOR_ENABLED = os.environ.get("TRANSLATOR_ENABLED", "true").lower() == "true"
# RSS-Feeds (Fallback, primär aus DB geladen)
RSS_FEEDS = {
"deutsch": [

Datei anzeigen

@@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS incidents (
type TEXT DEFAULT 'adhoc',
refresh_mode TEXT DEFAULT 'manual',
refresh_interval INTEGER DEFAULT 15,
refresh_start_time TEXT,
retention_days INTEGER DEFAULT 0,
visibility TEXT DEFAULT 'public',
summary TEXT,
@@ -116,6 +117,22 @@ CREATE TABLE IF NOT EXISTS refresh_log (
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS refresh_pipeline_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
refresh_log_id INTEGER REFERENCES refresh_log(id) ON DELETE CASCADE,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
pass_number INTEGER DEFAULT 1,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'pending',
count_value INTEGER,
count_secondary INTEGER,
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_incident ON refresh_pipeline_steps(incident_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_log ON refresh_pipeline_steps(refresh_log_id);
CREATE TABLE IF NOT EXISTS incident_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
@@ -362,6 +379,36 @@ async def init_db():
await db.commit()
logger.info("Migration: tenant_id zu incidents hinzugefuegt")
if "refresh_start_time" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN refresh_start_time TEXT")
await db.execute("UPDATE incidents SET refresh_start_time = '07:00' WHERE refresh_mode = 'auto'")
await db.commit()
logger.info("Migration: refresh_start_time zu incidents hinzugefuegt (bestehende Auto-Lagen auf 07:00)")
if "latest_developments" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN latest_developments TEXT")
await db.commit()
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
)
if not await cursor.fetchone():
await db.execute(
"""
CREATE TABLE podcast_transcripts (
url TEXT PRIMARY KEY,
transcript TEXT NOT NULL,
source TEXT NOT NULL,
segments_json TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
await db.commit()
logger.info("Migration: Tabelle podcast_transcripts angelegt")
# Migration: Token-Spalten fuer refresh_log
cursor = await db.execute("PRAGMA table_info(refresh_log)")
rl_columns = [row[1] for row in await cursor.fetchall()]
@@ -387,6 +434,29 @@ async def init_db():
await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit()
# Migration: refresh_pipeline_steps-Tabelle (Analysepipeline-Visualisierung)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_pipeline_steps'")
if not await cursor.fetchone():
await db.executescript("""
CREATE TABLE refresh_pipeline_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
refresh_log_id INTEGER REFERENCES refresh_log(id) ON DELETE CASCADE,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
pass_number INTEGER DEFAULT 1,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'pending',
count_value INTEGER,
count_secondary INTEGER,
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_incident ON refresh_pipeline_steps(incident_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_log ON refresh_pipeline_steps(refresh_log_id);
""")
await db.commit()
logger.info("Migration: refresh_pipeline_steps-Tabelle erstellt")
# Migration: notifications-Tabelle (fuer bestehende DBs)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
if not await cursor.fetchone():
@@ -552,6 +622,7 @@ async def init_db():
for idx_sql in [
"CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status ON incidents(tenant_id, status)",
"CREATE INDEX IF NOT EXISTS idx_articles_tenant_incident ON articles(tenant_id, incident_id)",
"CREATE INDEX IF NOT EXISTS idx_articles_incident_collected ON articles(incident_id, collected_at DESC)",
]:
try:
await db.execute(idx_sql)

Datei anzeigen

@@ -1,4 +1,4 @@
"""In-Memory Rate-Limiting fuer Magic-Link-Anfragen und Code-Verifizierung."""
"""In-Memory Rate-Limiting fuer Magic-Link-Anfragen."""
import time
from collections import defaultdict
@@ -51,52 +51,5 @@ class RateLimiter:
self._ip_requests[ip].append(now)
class VerifyCodeLimiter:
"""Rate-Limiter fuer Code-Verifizierung (Brute-Force-Schutz).
Zaehlt Fehlversuche pro E-Mail und pro IP.
Nach max_attempts wird gesperrt bis das Zeitfenster ablaeuft.
"""
def __init__(
self,
max_attempts_per_email: int = 5,
max_attempts_per_ip: int = 15,
window_seconds: int = 600, # 10 Minuten (= Magic-Link-Ablaufzeit)
):
self.max_per_email = max_attempts_per_email
self.max_per_ip = max_attempts_per_ip
self.window = window_seconds
self._email_failures: dict[str, list[float]] = defaultdict(list)
self._ip_failures: dict[str, list[float]] = defaultdict(list)
def _clean(self, entries: list[float]) -> list[float]:
cutoff = time.time() - self.window
return [t for t in entries if t > cutoff]
def check(self, email: str, ip: str) -> tuple[bool, str]:
"""Prueft ob ein Verifizierungsversuch erlaubt ist."""
self._email_failures[email] = self._clean(self._email_failures[email])
if len(self._email_failures[email]) >= self.max_per_email:
return False, "Zu viele Fehlversuche. Bitte neuen Code anfordern."
self._ip_failures[ip] = self._clean(self._ip_failures[ip])
if len(self._ip_failures[ip]) >= self.max_per_ip:
return False, "Zu viele Fehlversuche von dieser IP-Adresse."
return True, ""
def record_failure(self, email: str, ip: str):
"""Zeichnet einen fehlgeschlagenen Versuch auf."""
now = time.time()
self._email_failures[email].append(now)
self._ip_failures[ip].append(now)
def clear(self, email: str):
"""Loescht Zaehler nach erfolgreichem Login."""
self._email_failures.pop(email, None)
# Singleton-Instanzen
# Singleton-Instanz
magic_link_limiter = RateLimiter()
verify_code_limiter = VerifyCodeLimiter()

Datei anzeigen

@@ -1,8 +1,8 @@
"""HTML-E-Mail-Vorlagen fuer Magic Links, Einladungen und Benachrichtigungen."""
"""HTML-E-Mail-Vorlagen für Magic Links, Einladungen und Benachrichtigungen."""
def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, str]:
"""Erzeugt Login-E-Mail mit Magic Link und Code.
def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
"""Erzeugt Login-E-Mail mit Magic Link.
Returns:
(subject, html_body)
@@ -17,17 +17,16 @@ def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, st
<p style="margin: 0 0 16px 0;">Hallo {username},</p>
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Link oder geben Sie den Code ein, um sich anzumelden:</p>
<div style="background: #0f172a; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px 0;">
<div style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #f0b429; font-family: monospace;">{code}</div>
</div>
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, um sich anzumelden:</p>
<div style="text-align: center; margin: 0 0 24px 0;">
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Jetzt anmelden</a>
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gueltig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
<p style="color: #94a3b8; font-size: 13px; margin: 0 0 12px 0;">Oder kopieren Sie diesen Link in Ihren Browser:</p>
<p style="color: #64748b; font-size: 11px; word-break: break-all; margin: 0 0 24px 0;">{link}</p>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div>
</body>
</html>"""
@@ -39,25 +38,30 @@ def incident_notification_email(
incident_title: str,
notifications: list[dict],
dashboard_url: str,
incident_type: str = "adhoc",
) -> tuple[str, str]:
"""Erzeugt Benachrichtigungs-E-Mail fuer Lagen-Updates.
"""Erzeugt Benachrichtigungs-E-Mail für Lagen-Updates.
Args:
username: Empfaenger-Name
incident_title: Titel der Lage/Recherche
notifications: Liste von {"text": ..., "icon": ...} Dicts
dashboard_url: Link zum Dashboard
incident_type: "adhoc" oder "research"
Returns:
(subject, html_body)
"""
is_research = incident_type == "research"
type_label = "Recherche" if is_research else "Lagebild"
type_label_lower = "Recherche" if is_research else "Lage"
subject = f"AegisSight - {incident_title}"
icon_map = {
"success": "&#10003;", # Haekchen
"warning": "&#9888;", # Warndreieck
"error": "&#10007;", # Kreuz
"info": "&#9432;", # Info-Kreis
"success": "&#10003;",
"warning": "&#9888;",
"error": "&#10007;",
"info": "&#9432;",
}
color_map = {
"success": "#22c55e",
@@ -83,10 +87,10 @@ def incident_notification_email(
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 40px 20px;">
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 8px 0;">AegisSight Monitor</h1>
<p style="color: #94a3b8; font-size: 12px; margin: 0 0 24px 0;">Lagebericht-Benachrichtigung</p>
<p style="color: #94a3b8; font-size: 12px; margin: 0 0 24px 0;">{type_label} - Benachrichtigung</p>
<p style="margin: 0 0 8px 0;">Hallo {username},</p>
<p style="margin: 0 0 20px 0;">es gibt Neuigkeiten zur Lage <strong style="color: #f0b429;">{incident_title}</strong>:</p>
<p style="margin: 0 0 20px 0;">es gibt Neuigkeiten zur {type_label_lower} <strong style="color: #f0b429;">{incident_title}</strong>:</p>
<div style="background: #0f172a; border-radius: 8px; padding: 4px 16px; margin: 0 0 24px 0;">
{items_html}

184
src/feeds/podcast_parser.py Normale Datei
Datei anzeigen

@@ -0,0 +1,184 @@
"""Podcast-Feed-Parser: wie RSSParser, nur mit Transkript-Kaskade.
Aufbau bewusst copy-light zu rss_parser.py: dieselbe oeffentliche
Signatur `search_feeds_selective()`, eigener Code-Pfad mit Pre-Filter und
anschliessender Transkript-Kaskade via `transcript_extractors`.
Vorgaben des Plans:
- Keine kostenpflichtige API, keine lokale Transkription
- Episoden ohne auffindbares Transkript werden verworfen
- content_original wird NICHT auf 1000 Zeichen gekuerzt (Transkript-Volltext)
- Duplikate-Schutz zwischen Lagen ueber Cache-Tabelle podcast_transcripts
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
import feedparser
import httpx
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
from feeds.transcript_extractors import fetch_transcript
logger = logging.getLogger("osint.podcast")
class PodcastFeedParser:
"""Durchsucht Podcast-Feeds nach relevanten Episoden (mit Transkript)."""
STOP_WORDS = {
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
"auf", "für", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
"über", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
}
# Pre-Filter: wie im RSSParser — mindestens Haelfte der Keywords, max 2 notwendig
@staticmethod
def _prefilter_match(title: str, summary: str, keywords: list[str]) -> tuple[bool, float]:
text = f"{title} {summary}".lower()
if not keywords:
return True, 0.0
min_matches = min(2, max(1, (len(keywords) + 1) // 2))
match_count = sum(1 for kw in keywords if kw and kw in text)
if match_count >= min_matches:
return True, match_count / len(keywords)
return False, 0.0
async def search_feeds_selective(
self,
search_term: str,
selected_feeds: list[dict],
keywords: list[str] | None = None,
) -> list[dict]:
"""Durchsucht die uebergebenen Podcast-Feeds nach relevanten Episoden.
Signatur bewusst identisch zu RSSParser.search_feeds_selective, damit
die Orchestrator-Logik analog aufgebaut werden kann.
"""
if not selected_feeds:
return []
if keywords:
search_words = [w.lower().strip() for w in keywords if w.strip()]
else:
search_words = [w.lower() for w in search_term.split() if len(w) > 2 and w.lower() not in self.STOP_WORDS]
search_words = self._clean_search_words(search_words)
if not search_words:
return []
# Feeds parallel abfragen
tasks = [self._fetch_feed(feed, search_words) for feed in selected_feeds]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_articles: list[dict] = []
for feed, r in zip(selected_feeds, results):
if isinstance(r, Exception):
logger.debug(f"Podcast-Feed {feed.get('name')} fehlgeschlagen: {r}")
continue
all_articles.extend(r)
all_articles = self._apply_domain_cap(all_articles)
logger.info(f"Podcast-Parser: {len(all_articles)} Episoden mit Transkript gefunden")
return all_articles
@staticmethod
def _clean_search_words(words: list[str]) -> list[str]:
cleaned = [w for w in words if not w.isdigit()]
return cleaned if cleaned else words
async def _fetch_feed(self, feed_config: dict, search_words: list[str]) -> list[dict]:
"""Einzelnen Podcast-Feed abrufen, Pre-Filter + Transkript-Kaskade."""
name = feed_config["name"]
url = feed_config["url"]
articles: list[dict] = []
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url, headers={"User-Agent": "OSINT-Monitor/1.0 (Podcast Aggregator)"})
response.raise_for_status()
feed = await asyncio.to_thread(feedparser.parse, response.text)
except Exception as e:
logger.debug(f"Podcast-Feed {name} ({url}): {e}")
return articles
# Pro Feed maximal die 20 neuesten Episoden betrachten.
# Podcasts veroeffentlichen seltener als RSS-Feeds; 20 reicht fuer
# einen mehrmonatigen Rueckblick und begrenzt den Scrape-Aufwand.
entries = list(feed.entries[:20])
# Kandidaten nach Pre-Filter sammeln (keine Transkript-Abfrage dafuer).
candidates = []
for entry in entries:
title = entry.get("title", "")
summary = entry.get("summary", "") or entry.get("description", "")
passed, score = self._prefilter_match(title, summary, search_words)
if passed:
candidates.append((entry, title, summary, score))
if not candidates:
return articles
# Transkript-Kaskade parallel nur fuer die Kandidaten
transcript_tasks = [fetch_transcript(e, url, e.get("link")) for e, _t, _s, _r in candidates]
transcript_results = await asyncio.gather(*transcript_tasks, return_exceptions=True)
for (entry, title, summary, score), t_result in zip(candidates, transcript_results):
if isinstance(t_result, Exception):
logger.debug(f"Transkript-Kaskade fuer {entry.get('link')}: {t_result}")
continue
if not t_result or not t_result.text:
# Ohne Transkript keine Uebernahme (Plan-Vorgabe)
continue
# Nach-Transkript-Filter: wenn der Pre-Filter nur knapp griff,
# muss das Transkript die Keywords ebenfalls enthalten — sonst ist
# die Episode nicht wirklich relevant (Shownotes-Zufallstreffer).
if not self._transcript_confirms(t_result.text, search_words):
continue
published = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).astimezone(TIMEZONE).isoformat()
except (TypeError, ValueError):
pass
# WICHTIG: Transkript-Volltext, KEINE 1000-Zeichen-Kuerzung wie bei RSS.
articles.append({
"headline": title,
"headline_de": title,
"source": name,
"source_url": entry.get("link", ""),
"content_original": t_result.text,
"content_de": t_result.text,
"language": "de",
"published_at": published,
"relevance_score": score,
})
return articles
@staticmethod
def _transcript_confirms(transcript: str, keywords: list[str]) -> bool:
"""Prueft, dass mind. ein Keyword auch im Transkript vorkommt."""
if not keywords:
return True
text = transcript.lower()
return any(kw in text for kw in keywords if kw)
def _apply_domain_cap(self, articles: list[dict]) -> list[dict]:
"""Begrenzt die Anzahl der Episoden pro Domain (analog RSSParser)."""
if not articles:
return articles
by_domain: dict[str, list[dict]] = {}
for a in articles:
dom = _extract_domain(a.get("source_url", "")) or "_unknown"
by_domain.setdefault(dom, []).append(a)
out: list[dict] = []
for dom, items in by_domain.items():
items.sort(key=lambda x: x.get("relevance_score", 0.0), reverse=True)
out.extend(items[:MAX_ARTICLES_PER_DOMAIN_RSS])
return out

Datei anzeigen

@@ -6,6 +6,8 @@ import httpx
from datetime import datetime, timezone
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
from feeds.transcript_extractors._common import html_to_text
from services.post_refresh_qc import normalize_german_umlauts
logger = logging.getLogger("osint.rss")
@@ -152,11 +154,27 @@ class RSSParser:
for entry in feed.entries[:50]:
title = entry.get("title", "")
summary = entry.get("summary", "")
# RSS-summary ist bei vielen Quellen HTML (Guardian, AP, SZ, ...).
# Vor weiterer Verwendung strippen, sonst landet HTML in DB
# und KI-Agenten und Sprach-Heuristik werden gestoert.
summary_raw = entry.get("summary", "")
summary = html_to_text(summary_raw) if summary_raw else ""
# ASCII-Umlaut-Normalisierung (z.B. dpa-AFX schreibt "Gespraeche").
# Dictionary-basiert, sicher gegen englische Woerter wie "Boeing".
title, _ = normalize_german_umlauts(title)
summary, _ = normalize_german_umlauts(summary)
text = f"{title} {summary}".lower()
# Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet)
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
# Adaptive Match-Schwelle:
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
specific_in_text = any(w in text for w in search_words if len(w) >= 7)
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)
if match_count >= min_matches:

Datei anzeigen

@@ -0,0 +1,121 @@
"""Kaskaden-Dispatcher fuer Podcast-Transkript-Bezug.
Reihenfolge der Strategien:
1. rss_native — Podcasting-2.0-Tag <podcast:transcript> im Feed-Entry
2. website_* — Redaktionelles Manuskript auf der Episoden-Webseite
(sender-spezifische Adapter)
Episoden ohne Treffer in einer der Stufen werden verworfen (kein Fehler).
YouTube-Fallback wird nicht genutzt.
Jeder Adapter implementiert:
def can_handle(feed_entry: dict, feed_url: str) -> bool
async def fetch(feed_entry: dict, feed_url: str) -> TranscriptResult | None
Wer None liefert, gibt der naechsten Stufe die Chance. Wer einen
TranscriptResult liefert, beendet die Kaskade fuer diese Episode.
Der Dispatcher kuemmert sich um das Caching gegen die Tabelle
`podcast_transcripts` — eine einmal gefundene Episode wird bei folgenden
Refreshes (auch in anderen Lagen) direkt aus dem Cache geholt.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger("osint.podcast.extractors")
@dataclass
class TranscriptResult:
"""Einheitliches Ergebnis einer Transkript-Strategie."""
text: str
source: str # "rss_native" / "website_scrape"
segments: Optional[list] = None # Optional: [{"start": sec, "end": sec, "text": "..."}]
# Reihenfolge der Kaskade: zuerst Feed-Tag, dann Senderseiten
from . import rss_native
from . import website_dlf
from . import website_sz
from . import website_spiegel
from . import website_ndr
_EXTRACTORS = [
rss_native,
website_dlf,
website_sz,
website_spiegel,
website_ndr,
]
async def fetch_transcript(feed_entry: dict, feed_url: str, episode_url: str) -> Optional[TranscriptResult]:
"""Versucht Kaskade durch bis eine Stufe liefert.
Vor dem Kaskaden-Lauf wird der Cache (Tabelle `podcast_transcripts`) gegen
episode_url geprueft. Trifft der Cache, wird ohne HTTP-Request ausgeliefert.
"""
if not episode_url:
return None
from database import get_db
db = await get_db()
try:
cursor = await db.execute(
"SELECT transcript, source, segments_json FROM podcast_transcripts WHERE url = ?",
(episode_url,),
)
row = await cursor.fetchone()
if row:
segments = None
if row["segments_json"]:
try:
segments = json.loads(row["segments_json"])
except json.JSONDecodeError:
segments = None
logger.debug(f"Transkript-Cache-Hit: {episode_url}")
return TranscriptResult(text=row["transcript"], source=row["source"], segments=segments)
finally:
await db.close()
# Kaskade: erste Stufe, die can_handle(True) und ein Ergebnis liefert, gewinnt.
for extractor in _EXTRACTORS:
try:
if not extractor.can_handle(feed_entry, feed_url):
continue
result = await extractor.fetch(feed_entry, feed_url)
if result and result.text and result.text.strip():
await _store_in_cache(episode_url, result)
logger.info(
f"Transkript via {result.source} fuer {episode_url} "
f"({len(result.text)} Zeichen)"
)
return result
except Exception as e:
logger.warning(f"Extraktor {extractor.__name__} fuer {episode_url}: {e}")
continue
logger.debug(f"Kein Transkript verfuegbar: {episode_url}")
return None
async def _store_in_cache(url: str, result: TranscriptResult) -> None:
"""Legt das Transkript in der Cache-Tabelle ab (INSERT OR REPLACE)."""
from database import get_db
db = await get_db()
try:
segments_json = json.dumps(result.segments, ensure_ascii=False) if result.segments else None
await db.execute(
"INSERT OR REPLACE INTO podcast_transcripts (url, transcript, source, segments_json) "
"VALUES (?, ?, ?, ?)",
(url, result.text, result.source, segments_json),
)
await db.commit()
except Exception as e:
logger.warning(f"Cache-Write fuer {url} fehlgeschlagen: {e}")
finally:
await db.close()

Datei anzeigen

@@ -0,0 +1,170 @@
"""Gemeinsame Helfer fuer Website-Scrape-Adapter.
HTML-Extraktor ohne externe Abhaengigkeiten (BeautifulSoup nicht in
requirements.txt). Nutzt Regex fuer robusten Plaintext-Extract aus
typischen Artikel-Containern.
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from urllib.parse import urlparse
import httpx
logger = logging.getLogger("osint.podcast.extractors.common")
HTTP_TIMEOUT = 20.0
MIN_TRANSCRIPT_LEN = 500 # Unter 500 Zeichen ist das kein Manuskript, nur Shownotes
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; OSINT-Monitor/1.0; +https://monitor.aegis-sight.de)",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
}
def matches_domain(url: str, domains: tuple[str, ...]) -> bool:
"""Prueft, ob die URL zu einer der bekannten Sender-Domains gehoert."""
if not url:
return False
try:
host = urlparse(url).hostname or ""
host = host.lower().lstrip("www.")
return any(host == d or host.endswith("." + d) for d in domains)
except Exception:
return False
def episode_url(feed_entry: dict) -> Optional[str]:
"""Holt die Episoden-Webseite (meist entry.link)."""
if isinstance(feed_entry, dict):
return feed_entry.get("link") or feed_entry.get("guid")
return getattr(feed_entry, "link", None) or getattr(feed_entry, "guid", None)
async def fetch_html(url: str) -> Optional[str]:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT, follow_redirects=True, headers=DEFAULT_HEADERS) as client:
try:
resp = await client.get(url)
resp.raise_for_status()
return resp.text
except Exception as e:
logger.debug(f"HTML-Fetch fehlgeschlagen ({url}): {e}")
return None
# --- HTML-Extraktion ------------------------------------------------------
_SCRIPT_STYLE_RE = re.compile(r"<(script|style|noscript|iframe)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
_WHITESPACE_RE = re.compile(r"\s+")
def extract_text_by_container(html: str, container_patterns: list[str]) -> Optional[str]:
"""Extrahiert Text aus dem ersten gefundenen Container.
container_patterns: Liste von Regex-Mustern, die den oeffnenden Container-Tag
matchen (z. B. r'<article[^>]*class="[^"]*article-body[^"]*"[^>]*>').
Intern wird der zugehoerige schliessende Tag per Tag-Balancing gesucht.
"""
html_clean = _COMMENT_RE.sub("", _SCRIPT_STYLE_RE.sub("", html))
for pattern in container_patterns:
m = re.search(pattern, html_clean, re.IGNORECASE)
if not m:
continue
start = m.start()
# Tag-Name aus Pattern-Treffer extrahieren
tag_match = re.match(r"<(\w+)", m.group(0))
if not tag_match:
continue
tag_name = tag_match.group(1).lower()
end = _find_matching_close(html_clean, start, tag_name)
if end < 0:
continue
block = html_clean[start:end]
text = html_to_text(block)
if len(text) >= MIN_TRANSCRIPT_LEN:
return text
return None
def extract_longest_article_block(html: str) -> Optional[str]:
"""Fallback: suche den laengsten zusammenhaengenden Block aus <p>-Tags.
Nuetzlich, wenn spezifische Container-Selektoren fehlschlagen.
"""
html_clean = _COMMENT_RE.sub("", _SCRIPT_STYLE_RE.sub("", html))
# Alle <article>- und <main>-Bloecke finden
candidates = []
for tag in ("article", "main"):
for m in re.finditer(rf"<{tag}\b[^>]*>", html_clean, re.IGNORECASE):
end = _find_matching_close(html_clean, m.start(), tag)
if end > m.start():
candidates.append(html_clean[m.start():end])
if not candidates:
# Letzter Ausweg: gesamter Body
body_m = re.search(r"<body\b[^>]*>", html_clean, re.IGNORECASE)
if body_m:
candidates.append(html_clean[body_m.start():])
best_text = ""
for block in candidates:
text = html_to_text(block)
if len(text) > len(best_text):
best_text = text
return best_text if len(best_text) >= MIN_TRANSCRIPT_LEN else None
def html_to_text(html: str) -> str:
"""Simple HTML→Plaintext-Konvertierung."""
no_tags = _COMMENT_RE.sub("", _SCRIPT_STYLE_RE.sub("", html))
no_tags = _TAG_RE.sub(" ", no_tags)
no_tags = (no_tags
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&quot;", '"')
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&ndash;", "-")
.replace("&mdash;", "-")
.replace("&auml;", "ä")
.replace("&ouml;", "ö")
.replace("&uuml;", "ü")
.replace("&Auml;", "Ä")
.replace("&Ouml;", "Ö")
.replace("&Uuml;", "Ü")
.replace("&szlig;", "ß"))
return _WHITESPACE_RE.sub(" ", no_tags).strip()
def _find_matching_close(html: str, start: int, tag_name: str) -> int:
"""Findet die Position des schliessenden Tags, der zum oeffnenden Tag an `start` gehoert.
Einfacher Zaehler-Ansatz: jeder weitere <tag> erhoeht, jeder </tag> verringert.
Rueckgabe: Index NACH dem schliessenden Tag, -1 falls nicht gefunden.
"""
open_re = re.compile(rf"<{tag_name}\b[^>]*>", re.IGNORECASE)
close_re = re.compile(rf"</{tag_name}>", re.IGNORECASE)
depth = 1
pos = start + 1 # nach dem initial geoeffneten Tag
while pos < len(html) and depth > 0:
next_open = open_re.search(html, pos)
next_close = close_re.search(html, pos)
if not next_close:
return -1
if next_open and next_open.start() < next_close.start():
depth += 1
pos = next_open.end()
else:
depth -= 1
pos = next_close.end()
return pos if depth == 0 else -1

Datei anzeigen

@@ -0,0 +1,182 @@
"""Stufe 1: Podcasting-2.0-Tag <podcast:transcript> im Feed-Entry.
Wenn der Podcast-Herausgeber den offenen Podcasting-2.0-Standard nutzt,
liegt im Feed-Entry ein oder mehrere <podcast:transcript>-Tags mit Link
zu SRT/VTT/HTML/JSON. Das ist die zuverlaessigste Quelle ueberhaupt und
verursacht nur einen HTTP-Request.
"""
from __future__ import annotations
import logging
import re
from typing import Optional
import httpx
from . import TranscriptResult
logger = logging.getLogger("osint.podcast.extractors.rss_native")
# Reihenfolge der akzeptierten Formate (mehr Struktur bevorzugt)
_PREFERRED_MIME = ["application/json", "text/vtt", "application/x-subrip", "text/srt", "text/html", "text/plain"]
def can_handle(feed_entry: dict, feed_url: str) -> bool:
"""Greift immer, wenn feedparser einen podcast:transcript-Link erkannt hat."""
return bool(_find_transcript_links(feed_entry))
async def fetch(feed_entry: dict, feed_url: str) -> Optional[TranscriptResult]:
links = _find_transcript_links(feed_entry)
if not links:
return None
# Bestes Format auswaehlen (nach _PREFERRED_MIME)
links_sorted = sorted(
links,
key=lambda l: _PREFERRED_MIME.index(l.get("type", "")) if l.get("type") in _PREFERRED_MIME else 99,
)
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
for link in links_sorted:
url = link.get("url")
if not url:
continue
try:
resp = await client.get(url, headers={"User-Agent": "OSINT-Monitor/1.0 (Podcast-Transcript)"})
resp.raise_for_status()
raw = resp.text
mime = (link.get("type") or "").lower()
text, segments = _parse_by_mime(raw, mime)
if text and text.strip():
return TranscriptResult(text=text.strip(), source="rss_native", segments=segments)
except Exception as e:
logger.debug(f"Link {url} fehlgeschlagen: {e}")
continue
return None
def _find_transcript_links(feed_entry: dict) -> list[dict]:
"""Findet <podcast:transcript>-Angaben im feedparser-Entry.
feedparser bildet Namespace-Tags als Dicts mit 'url' und 'type' ab
(z. B. entry.podcast_transcript oder entry['podcast_transcript']).
Je nach feedparser-Version kann das ein einzelnes Dict oder eine Liste sein.
"""
candidates = []
for key in ("podcast_transcript", "podcast_transcripts", "transcripts"):
val = feed_entry.get(key) if isinstance(feed_entry, dict) else getattr(feed_entry, key, None)
if not val:
continue
if isinstance(val, list):
candidates.extend([v for v in val if isinstance(v, dict)])
elif isinstance(val, dict):
candidates.append(val)
# Zusaetzlich: manche Feeds schreiben die Tags ins links-Array mit rel="transcript"
links = feed_entry.get("links") if isinstance(feed_entry, dict) else getattr(feed_entry, "links", None) or []
for link in links or []:
if isinstance(link, dict) and link.get("rel") == "transcript" and link.get("href"):
candidates.append({"url": link["href"], "type": link.get("type", "")})
return candidates
def _parse_by_mime(raw: str, mime: str) -> tuple[str, Optional[list]]:
"""Extrahiert Plaintext und (wenn moeglich) Segmente nach MIME-Typ."""
if "json" in mime:
return _parse_json(raw)
if "vtt" in mime:
return _parse_vtt(raw)
if "subrip" in mime or "srt" in mime:
return _parse_srt(raw)
if "html" in mime:
return _parse_html(raw), None
# Fallback: Plaintext
return raw, None
def _parse_json(raw: str) -> tuple[str, Optional[list]]:
"""Podcasting-2.0 JSON-Transcript-Format."""
import json
try:
data = json.loads(raw)
segments_raw = data.get("segments", [])
texts = []
segments = []
for seg in segments_raw:
body = seg.get("body", "").strip()
if body:
texts.append(body)
segments.append({
"start": seg.get("startTime"),
"end": seg.get("endTime"),
"text": body,
})
return "\n".join(texts), segments or None
except Exception:
return "", None
def _parse_vtt(raw: str) -> tuple[str, Optional[list]]:
"""WebVTT-Parser (ohne externe Abhaengigkeiten)."""
lines = raw.splitlines()
blocks = []
current = []
time_re = re.compile(r"(\d{2}:)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}:)?(\d{2}):(\d{2})\.(\d{3})")
def finalize_block(block: list) -> Optional[dict]:
if len(block) < 2:
return None
time_line = next((l for l in block if time_re.search(l)), None)
text_lines = [l for l in block if not time_re.search(l) and l.strip() and not l.strip().isdigit()]
if not time_line or not text_lines:
return None
m = time_re.search(time_line)
start = _time_to_sec(m.group(1), m.group(2), m.group(3), m.group(4))
end = _time_to_sec(m.group(5), m.group(6), m.group(7), m.group(8))
return {"start": start, "end": end, "text": " ".join(text_lines).strip()}
for line in lines:
if line.strip() == "":
b = finalize_block(current)
if b:
blocks.append(b)
current = []
else:
current.append(line)
b = finalize_block(current)
if b:
blocks.append(b)
text = " ".join(b["text"] for b in blocks)
return text, blocks or None
def _parse_srt(raw: str) -> tuple[str, Optional[list]]:
"""SubRip-Parser (Timecodes mit Komma statt Punkt)."""
return _parse_vtt(raw.replace(",", "."))
def _parse_html(raw: str) -> str:
"""HTML → Plaintext. Entfernt Tags simpel via Regex (genuegt fuer Transcript-HTML)."""
no_tags = re.sub(r"<script.*?</script>", " ", raw, flags=re.DOTALL | re.IGNORECASE)
no_tags = re.sub(r"<style.*?</style>", " ", no_tags, flags=re.DOTALL | re.IGNORECASE)
no_tags = re.sub(r"<[^>]+>", " ", no_tags)
# HTML-Entitys grob zuruecksetzen
no_tags = (no_tags
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&quot;", '"')
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">"))
no_tags = re.sub(r"\s+", " ", no_tags)
return no_tags.strip()
def _time_to_sec(h: Optional[str], m: str, s: str, ms: str) -> float:
"""Konvertiert VTT-Timecode in Sekunden."""
hours = int(h.rstrip(":")) if h else 0
return hours * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0

Datei anzeigen

@@ -0,0 +1,61 @@
"""Deutschlandfunk: Manuskripte auf den Sender-Websites.
Domains:
- deutschlandfunk.de
- deutschlandfunkkultur.de
- deutschlandfunknova.de
Dlf-Artikel-HTML enthaelt den Manuskript-Text typischerweise in
<article class="b-article">...</article> mit vielen <p>-Absaetzen
oder als <div class="b-text">. Als Fallback greift der generische
Longest-Article-Block-Extraktor.
"""
from __future__ import annotations
import logging
from typing import Optional
from . import TranscriptResult
from ._common import (
episode_url,
extract_longest_article_block,
extract_text_by_container,
fetch_html,
matches_domain,
)
logger = logging.getLogger("osint.podcast.extractors.dlf")
_DOMAINS = (
"deutschlandfunk.de",
"deutschlandfunkkultur.de",
"deutschlandfunknova.de",
)
_CONTAINER_PATTERNS = [
r'<article[^>]*class="[^"]*b-article[^"]*"[^>]*>',
r'<div[^>]*class="[^"]*b-text[^"]*"[^>]*>',
r'<article\b[^>]*>',
r'<main\b[^>]*>',
]
def can_handle(feed_entry: dict, feed_url: str) -> bool:
url = episode_url(feed_entry) or feed_url
return matches_domain(url, _DOMAINS) or matches_domain(feed_url, _DOMAINS)
async def fetch(feed_entry: dict, feed_url: str) -> Optional[TranscriptResult]:
url = episode_url(feed_entry)
if not url:
return None
html = await fetch_html(url)
if not html:
return None
text = extract_text_by_container(html, _CONTAINER_PATTERNS)
if not text:
text = extract_longest_article_block(html)
if not text:
return None
return TranscriptResult(text=text, source="website_scrape")

Datei anzeigen

@@ -0,0 +1,51 @@
"""Norddeutscher Rundfunk: Manuskripte auf ndr.de.
NDR-Sendungen (insbesondere NDR Info „Streitkraefte und Strategien") stellen
Manuskripte auf der Episodenseite bereit, typischerweise in
<article class="article"> oder <div id="mainContent">.
"""
from __future__ import annotations
import logging
from typing import Optional
from . import TranscriptResult
from ._common import (
episode_url,
extract_longest_article_block,
extract_text_by_container,
fetch_html,
matches_domain,
)
logger = logging.getLogger("osint.podcast.extractors.ndr")
_DOMAINS = ("ndr.de",)
_CONTAINER_PATTERNS = [
r'<article[^>]*class="[^"]*article[^"]*"[^>]*>',
r'<div[^>]*id="mainContent"[^>]*>',
r'<article\b[^>]*>',
r'<main\b[^>]*>',
]
def can_handle(feed_entry: dict, feed_url: str) -> bool:
url = episode_url(feed_entry) or feed_url
return matches_domain(url, _DOMAINS) or matches_domain(feed_url, _DOMAINS)
async def fetch(feed_entry: dict, feed_url: str) -> Optional[TranscriptResult]:
url = episode_url(feed_entry)
if not url:
return None
html = await fetch_html(url)
if not html:
return None
text = extract_text_by_container(html, _CONTAINER_PATTERNS)
if not text:
text = extract_longest_article_block(html)
if not text:
return None
return TranscriptResult(text=text, source="website_scrape")

Datei anzeigen

@@ -0,0 +1,51 @@
"""Der Spiegel: Manuskripte auf spiegel.de.
SPIEGEL-Artikel haben typischerweise einen <article data-article-el>-Container.
SPIEGEL+-Artikel liefern ohne Login nur Teaser — der Length-Check in _common
sorgt dafuer, dass solche Teaser verworfen werden und die Kaskade weiterlaeuft.
"""
from __future__ import annotations
import logging
from typing import Optional
from . import TranscriptResult
from ._common import (
episode_url,
extract_longest_article_block,
extract_text_by_container,
fetch_html,
matches_domain,
)
logger = logging.getLogger("osint.podcast.extractors.spiegel")
_DOMAINS = ("spiegel.de", "manager-magazin.de")
_CONTAINER_PATTERNS = [
r'<main[^>]*data-area="article"[^>]*>',
r'<article[^>]*data-article-el[^>]*>',
r'<article\b[^>]*>',
r'<main\b[^>]*>',
]
def can_handle(feed_entry: dict, feed_url: str) -> bool:
url = episode_url(feed_entry) or feed_url
return matches_domain(url, _DOMAINS) or matches_domain(feed_url, _DOMAINS)
async def fetch(feed_entry: dict, feed_url: str) -> Optional[TranscriptResult]:
url = episode_url(feed_entry)
if not url:
return None
html = await fetch_html(url)
if not html:
return None
text = extract_text_by_container(html, _CONTAINER_PATTERNS)
if not text:
text = extract_longest_article_block(html)
if not text:
return None
return TranscriptResult(text=text, source="website_scrape")

Datei anzeigen

@@ -0,0 +1,53 @@
"""Sueddeutsche Zeitung: Manuskripte auf sz.de.
Achtung: Viele SZ-Artikel sind hinter Paywall (SZ Plus). Der Scraper holt
den Inhalt, der ohne Login ausgeliefert wird. Ist nur ein Teaser vorhanden,
ist der Text-Length-Check in _common.MIN_TRANSCRIPT_LEN die Schutzschicht:
kurze Teaser werden verworfen, und der Aufrufer faellt auf die naechste
Kaskaden-Stufe (z. B. YouTube) zurueck — ohne Fehler.
"""
from __future__ import annotations
import logging
from typing import Optional
from . import TranscriptResult
from ._common import (
episode_url,
extract_longest_article_block,
extract_text_by_container,
fetch_html,
matches_domain,
)
logger = logging.getLogger("osint.podcast.extractors.sz")
_DOMAINS = ("sz.de", "sueddeutsche.de")
_CONTAINER_PATTERNS = [
r'<article[^>]*class="[^"]*article-body[^"]*"[^>]*>',
r'<article[^>]*id="article-app-container"[^>]*>',
r'<article\b[^>]*>',
r'<main\b[^>]*>',
]
def can_handle(feed_entry: dict, feed_url: str) -> bool:
url = episode_url(feed_entry) or feed_url
return matches_domain(url, _DOMAINS) or matches_domain(feed_url, _DOMAINS)
async def fetch(feed_entry: dict, feed_url: str) -> Optional[TranscriptResult]:
url = episode_url(feed_entry)
if not url:
return None
html = await fetch_html(url)
if not html:
return None
text = extract_text_by_container(html, _CONTAINER_PATTERNS)
if not text:
text = extract_longest_article_block(html)
if not text:
return None
return TranscriptResult(text=text, source="website_scrape")

Datei anzeigen

@@ -5,7 +5,7 @@ import logging
import os
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
@@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler()
async def check_auto_refresh():
"""Prüft welche Lagen einen Auto-Refresh brauchen."""
"""Prüft welche Lagen einen Auto-Refresh brauchen (Slot-basiert)."""
db = await get_db()
try:
cursor = await db.execute(
"SELECT id, refresh_interval FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'"
"SELECT id, refresh_interval, refresh_start_time FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'"
)
incidents = await cursor.fetchall()
@@ -120,18 +120,72 @@ async def check_auto_refresh():
for incident in incidents:
incident_id = incident["id"]
interval = incident["refresh_interval"]
start_time_str = incident["refresh_start_time"]
# Letzten abgeschlossenen Refresh prüfen (egal ob auto oder manual)
# Letzten abgeschlossenen oder laufenden Refresh pruefen
cursor = await db.execute(
"SELECT started_at FROM refresh_log WHERE incident_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1",
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running', 'cancelled', 'error') ORDER BY id DESC LIMIT 1",
(incident_id,),
)
last_refresh = await cursor.fetchone()
# Laufenden Refresh ueberspringen
if last_refresh and last_refresh["status"] == "running":
logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)")
continue
should_refresh = False
if not last_refresh:
# Noch nie gelaufen -> sofort starten
should_refresh = True
logger.info(f"Auto-Refresh Lage {incident_id}: erster Refresh")
elif start_time_str:
# Slot-basierte Logik: Naechsten faelligen Slot berechnen
try:
start_h, start_m = map(int, start_time_str.split(":"))
except (ValueError, AttributeError):
logger.warning(f"Auto-Refresh Lage {incident_id}: ungueltiges Startzeit-Format '{start_time_str}'")
continue
last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=TIMEZONE)
else:
last_time = last_time.astimezone(TIMEZONE)
# Anker: heute um start_time
anchor_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0)
interval_td = timedelta(minutes=interval)
if interval >= 1440:
# Taeglicher oder laengerer Rhythmus
days_interval = interval // 1440
# Letzter Slot der <= now ist
current_slot = anchor_today
if current_slot > now:
current_slot -= timedelta(days=days_interval)
# Sicherheitsschleife: weiter zurueck falls noetig
while current_slot > now:
current_slot -= timedelta(days=days_interval)
else:
# Untertaegig: Slots ab Anker im Intervall-Takt
# Anker zurueck bis vor last_refresh
ref_anchor = anchor_today
while ref_anchor > last_time:
ref_anchor -= interval_td
# Von dort vorwaerts bis zum letzten Slot <= now
current_slot = ref_anchor
while current_slot + interval_td <= now:
current_slot += interval_td
if current_slot > last_time:
should_refresh = True
logger.info(f"Auto-Refresh Lage {incident_id}: Slot {current_slot.strftime('%H:%M')} faellig (letzter Refresh: {last_time.strftime('%Y-%m-%d %H:%M')})")
else:
logger.debug(f"Auto-Refresh Lage {incident_id}: kein faelliger Slot (letzter: {current_slot.strftime('%H:%M')})")
else:
# Fallback: altes Intervall-Verhalten (kein start_time gesetzt)
last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=TIMEZONE)
@@ -145,15 +199,6 @@ async def check_auto_refresh():
logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig")
if should_refresh:
# Prüfen ob bereits ein laufender Refresh existiert
running_cursor = await db.execute(
"SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1",
(incident_id,),
)
if await running_cursor.fetchone():
logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)")
continue
await orchestrator.enqueue_refresh(incident_id, trigger_type="auto")
except Exception as e:
@@ -332,8 +377,8 @@ from routers.notifications import router as notifications_router
from routers.feedback import router as feedback_router
from routers.public_api import router as public_api_router
from routers.chat import router as chat_router
from routers.network_analysis import router as network_analysis_router
from routers.tutorial import router as tutorial_router
from routes.version_router import router as version_router
app.include_router(auth_router)
app.include_router(incidents_router)
@@ -342,8 +387,8 @@ app.include_router(notifications_router)
app.include_router(feedback_router)
app.include_router(public_api_router)
app.include_router(chat_router, prefix="/api/chat")
app.include_router(network_analysis_router)
app.include_router(tutorial_router)
app.include_router(version_router)
@app.websocket("/api/ws")

Datei anzeigen

@@ -40,12 +40,25 @@ async def require_writable_license(
) -> dict:
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz (Nur-Lesen-Modus).
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz, deaktivierter Org
oder aufgebrauchtem Token-Budget (Hard-Stop).
"""
lic = current_user.get("license", {})
if lic.get("read_only"):
reason = lic.get("read_only_reason") or "expired"
if reason == "budget_exceeded":
detail = "Token-Budget aufgebraucht. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren."
elif reason == "expired":
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
elif reason == "no_license":
detail = "Keine aktive Lizenz. Bitte Verwaltung kontaktieren."
elif reason == "org_disabled":
detail = "Organisation deaktiviert. Bitte Support kontaktieren."
else:
detail = lic.get("message") or "Nur Lesezugriff moeglich."
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.",
detail=detail,
headers={"X-License-Status": reason},
)
return current_user

Datei anzeigen

@@ -18,10 +18,6 @@ class VerifyTokenRequest(BaseModel):
token: str
class VerifyCodeRequest(BaseModel):
email: str = Field(min_length=1, max_length=254)
code: str = Field(min_length=6, max_length=6)
class TokenResponse(BaseModel):
access_token: str
@@ -41,9 +37,12 @@ class UserMeResponse(BaseModel):
license_status: str = "unknown"
license_type: str = ""
read_only: bool = False
read_only_reason: Optional[str] = None
unlimited_budget: bool = False
credits_total: Optional[int] = None
credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None
is_global_admin: bool = False
# Incidents (Lagen)
@@ -53,6 +52,7 @@ class IncidentCreate(BaseModel):
type: str = Field(default="adhoc", pattern="^(adhoc|research)$")
refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$")
refresh_interval: int = Field(default=15, ge=10, le=10080)
refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = True
include_telegram: bool = False
@@ -66,13 +66,25 @@ class IncidentUpdate(BaseModel):
status: Optional[str] = Field(default=None, pattern="^(active|archived)$")
refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$")
refresh_interval: Optional[int] = Field(default=None, ge=10, le=10080)
refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
class DescriptionEnhanceRequest(BaseModel):
title: str = Field(min_length=3)
description: str | None = None
type: str = Field(default="adhoc", pattern="^(adhoc|research)$")
class IncidentResponse(BaseModel):
"""Vollstaendige Lage-Details (fuer GET /incidents/{id}).
Enthaelt summary + latest_developments, aber NICHT mehr sources_json —
das wird separat per GET /incidents/{id}/sources geladen (Lazy-Load).
"""
id: int
title: str
description: Optional[str]
@@ -80,10 +92,11 @@ class IncidentResponse(BaseModel):
status: str
refresh_mode: str
refresh_interval: int
refresh_start_time: Optional[str] = None
retention_days: int
visibility: str = "public"
summary: Optional[str]
sources_json: Optional[str] = None
latest_developments: Optional[str] = None
international_sources: bool = True
include_telegram: bool = False
created_by: int
@@ -94,6 +107,35 @@ class IncidentResponse(BaseModel):
source_count: int = 0
class IncidentListItem(BaseModel):
"""Schlankes Sidebar-Item (fuer GET /incidents).
Enthaelt, was Sidebar und Edit-Dialog brauchen — kein summary,
kein sources_json. Statt summary-Volltext ein ``has_summary``-Bit,
damit das Frontend "erster Refresh"-Zustand erkennen kann.
description bleibt drin (kurz, vom Edit-Modal direkt genutzt).
"""
id: int
title: str
description: Optional[str] = None
type: str
status: str
refresh_mode: str
refresh_interval: int
refresh_start_time: Optional[str] = None
retention_days: int
visibility: str = "public"
international_sources: bool = True
include_telegram: bool = False
created_by: int
created_by_username: str = ""
created_at: str
updated_at: str
article_count: int = 0
source_count: int = 0
has_summary: bool = False
# Sources (Quellenverwaltung)
@@ -101,7 +143,7 @@ class SourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel)$")
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$")
category: str = Field(default="sonstige", pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None
@@ -111,7 +153,7 @@ class SourceUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel)$")
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$")
category: Optional[str] = Field(default=None, pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None
@@ -201,3 +243,16 @@ class FeedbackRequest(BaseModel):
message: str = Field(min_length=10, max_length=5000)
# --- Global Admin: Org-Wechsel (herausnehmbar) ---
class SwitchOrgRequest(BaseModel):
organization_id: int
class OrgListItem(BaseModel):
id: int
name: str
slug: str
is_active: bool

Datei anzeigen

@@ -1,59 +0,0 @@
"""Pydantic Models für Netzwerkanalyse Request/Response Schemas."""
from pydantic import BaseModel, Field
from typing import Optional
class NetworkAnalysisCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
incident_ids: list[int] = Field(min_length=1)
class NetworkAnalysisUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
incident_ids: Optional[list[int]] = None
class NetworkEntityResponse(BaseModel):
id: int
name: str
name_normalized: str
entity_type: str
description: str = ""
aliases: list[str] = []
mention_count: int = 0
corrected_by_opus: bool = False
metadata: dict = {}
class NetworkRelationResponse(BaseModel):
id: int
source_entity_id: int
target_entity_id: int
category: str
label: str
description: str = ""
weight: int = 1
status: str = ""
evidence: list[str] = []
class NetworkAnalysisResponse(BaseModel):
id: int
name: str
status: str
entity_count: int = 0
relation_count: int = 0
has_update: bool = False
incident_ids: list[int] = []
incident_titles: list[str] = []
data_hash: Optional[str] = None
last_generated_at: Optional[str] = None
created_by: int = 0
created_by_username: str = ""
created_at: str = ""
class NetworkGraphResponse(BaseModel):
analysis: NetworkAnalysisResponse
entities: list[NetworkEntityResponse] = []
relations: list[NetworkRelationResponse] = []

965
src/report_generator.py Normale Datei
Datei anzeigen

@@ -0,0 +1,965 @@
"""Report-Generator: PDF und Word Berichte aus Lage-Daten."""
import base64
import io
import json
import logging
import re
import uuid
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import pikepdf
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from config import TIMEZONE, CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.report")
TEMPLATE_DIR = Path(__file__).parent / "report_templates"
LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
FC_STATUS_LABELS = {
# 1:1 vom Monitor-Frontend (components.js) — konsistent zum UI.
"confirmed": "Bestätigt",
"unconfirmed": "Unbestätigt",
"contradicted": "Widerlegt",
"developing": "Unklar",
"established": "Gesichert",
"disputed": "Umstritten",
"unverified": "Ungeprüft",
"false": "Falsch", # Legacy-Fallback
}
def _get_logo_base64() -> str:
"""Logo als Base64 für HTML-Embedding."""
try:
return base64.b64encode(LOGO_PATH.read_bytes()).decode()
except Exception:
return ""
def _prepare_sources(incident: dict) -> list:
"""Quellenverzeichnis aus sources_json parsen."""
raw = incident.get("sources_json")
if not raw:
return []
try:
return json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
return []
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())
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"]))})
return stats
def _prepare_fact_checks(fact_checks: list) -> list:
"""Faktenchecks mit Label aufbereiten."""
result = []
for fc in fact_checks:
fc_copy = dict(fc)
fc_copy["status_label"] = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", "Unbekannt"))
result.append(fc_copy)
return result
def _prepare_timeline(articles: list) -> list:
"""Timeline aus Artikeln: sortiert nach Datum."""
timeline = []
for art in articles:
pub = art.get("published_at") or art.get("collected_at") or ""
pub = str(pub) if pub else ""
headline = art.get("headline_de") or art.get("headline") or "Ohne Titel"
source = art.get("source") or ""
if pub:
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
date_str = dt.strftime("%d.%m.%Y %H:%M")
except Exception:
date_str = pub[:16]
else:
date_str = ""
timeline.append({"date": date_str, "headline": headline, "source": source, "sort_key": pub})
timeline.sort(key=lambda x: x["sort_key"], reverse=True)
return timeline[:100] # Max 100 Einträge
def _markdown_to_html(text: str) -> str:
"""Einfache Markdown -> HTML Konvertierung für Lagebild."""
if not text:
return "<p><em>Keine Zusammenfassung verfügbar.</em></p>"
# Basic Markdown -> HTML
html = text
# Headlines
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
# Bold
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Links [text](url)
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Bullet lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
html = re.sub(r'(<li>.*</li>\n?)+', lambda m: '<ul>' + m.group(0) + '</ul>', html)
# Paragraphs
paragraphs = html.split('\n\n')
result = []
for p in paragraphs:
p = p.strip()
if not p:
continue
if p.startswith('<h') or p.startswith('<ul') or p.startswith('<ol'):
result.append(p)
else:
result.append(f'<p>{p}</p>')
return '\n'.join(result)
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
Nimmt nur den ersten Abschnitt (bis zur zweiten H2/H3-Überschrift)
oder kürzt auf max_chars Zeichen mit sauberem Abbruch am Absatzende.
"""
if not summary_text or len(summary_text) <= max_chars:
return summary_text
lines = summary_text.split("\n")
result_lines = []
heading_count = 0
char_count = 0
for line in lines:
stripped = line.strip()
# Zähle Überschriften (## oder ###)
if stripped.startswith("## ") or stripped.startswith("### "):
heading_count += 1
# Nach der 3. Überschrift abbrechen (= 2 Abschnitte)
if heading_count > 3:
break
result_lines.append(line)
char_count += len(line) + 1
# Hard-Limit bei max_chars, aber am Absatzende abbrechen
if char_count > max_chars and stripped == "":
break
text = "\n".join(result_lines).rstrip()
if len(text) < len(summary_text) - 100:
text += "\n\n*[Vollständige Zusammenfassung im Vollständigen Bericht]*"
return text
def _strip_citation_numbers(text: str) -> str:
"""Entfernt [1234]-Quellenreferenzen aus dem Text."""
# Einzelne Referenzen: [1302]
text = re.sub(r"\s*\[\d{1,5}\]", "", text)
# Mehrfach-Referenzen: [725][765][768]
text = re.sub(r"(\[\d{1,5}\]){2,}", "", text)
# Aufräumen: Doppelte Leerzeichen
text = re.sub(r" +", " ", text)
return text
def _find_source_for_citation(num: str, sources: list) -> dict | None:
"""Sucht eine Quelle anhand der Zitat-Nummer (inkl. Suffix-Fallback wie 1383a -> 1383)."""
if not sources:
return None
for s in sources:
try:
if str(s.get("nr")) == num:
return s
except Exception:
continue
# Suffix-Fallback: 1383a -> 1383
if re.search(r"[a-z]$", num):
base = re.sub(r"[a-z]$", "", num)
for s in sources:
if str(s.get("nr")) == base:
return s
return None
def _linkify_citations_html(text: str, sources: list) -> str:
"""Ersetzt [1234]-Zitate durch HTML-Links zur jeweiligen Quelle.
Nummern ohne zugeordnete Quelle bleiben als sichtbare Zahl erhalten.
"""
if not text:
return text
if not sources:
return text
def repl(match: re.Match) -> str:
num = match.group(1)
src = _find_source_for_citation(num, sources)
if src and src.get("url"):
url = src["url"].replace('"', "&quot;")
name = (src.get("name") or "").replace('"', "&quot;")
return f'<a href="{url}" class="citation" title="{name}">[{num}]</a>'
return match.group(0)
return re.sub(r"\[(\d{1,5}[a-z]?)\]", repl, text)
def _add_docx_hyperlink(paragraph, url: str, text: str):
"""Fügt einen klickbaren Hyperlink in ein python-docx-Paragraph-Objekt ein."""
from docx.oxml.shared import OxmlElement, qn
part = paragraph.part
r_id = part.relate_to(
url,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
is_external=True,
)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(qn("r:id"), r_id)
new_run = OxmlElement("w:r")
rPr = OxmlElement("w:rPr")
color = OxmlElement("w:color")
color.set(qn("w:val"), "0066CC")
rPr.append(color)
u = OxmlElement("w:u")
u.set(qn("w:val"), "single")
rPr.append(u)
sz = OxmlElement("w:sz")
sz.set(qn("w:val"), "20")
rPr.append(sz)
new_run.append(rPr)
t = OxmlElement("w:t")
t.text = text
t.set(qn("xml:space"), "preserve")
new_run.append(t)
hyperlink.append(new_run)
paragraph._p.append(hyperlink)
return hyperlink
def _add_docx_paragraph_with_citations(doc_or_para, text: str, sources: list, style: str | None = None):
"""Fügt ein Paragraph hinzu, bei dem [1234]-Zitate als Hyperlink-Runs eingefügt werden.
doc_or_para darf ein Document sein (neues Paragraph wird angelegt) oder bereits ein Paragraph.
"""
if hasattr(doc_or_para, "add_paragraph"):
para = doc_or_para.add_paragraph(style=style) if style else doc_or_para.add_paragraph()
else:
para = doc_or_para
pattern = re.compile(r"\[(\d{1,5}[a-z]?)\]")
pos = 0
for m in pattern.finditer(text):
if m.start() > pos:
para.add_run(text[pos:m.start()])
num = m.group(1)
src = _find_source_for_citation(num, sources)
if src and src.get("url"):
_add_docx_hyperlink(para, src["url"], f"[{num}]")
else:
para.add_run(m.group(0))
pos = m.end()
if pos < len(text):
para.add_run(text[pos:])
return para
def _extract_zusammenfassung_lines(summary_text: str) -> tuple[list[str], str]:
"""Extrahiert die ZUSAMMENFASSUNG-Sektion als Liste von Rohzeilen (ohne Zitatbearbeitung).
Returns:
(lines, remaining_summary)
"""
if not summary_text:
return [], summary_text
pattern = r"(## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n)(.*?)(?=\n## |\Z)"
match = re.search(pattern, summary_text, re.DOTALL)
if not match:
return [], summary_text
zusammenfassung_raw = match.group(2).strip()
remaining = summary_text[:match.start()] + summary_text[match.end():]
remaining = remaining.strip()
lines: list[str] = []
for line in zusammenfassung_raw.split("\n"):
stripped = line.strip()
if stripped.startswith("- ") or stripped.startswith("* "):
content = stripped[2:].strip()
if content:
lines.append(content)
elif stripped and not stripped.startswith("#"):
lines.append(stripped)
return lines, remaining
def _extract_zusammenfassung(summary_text: str, sources: list | None = None) -> tuple[str, str]:
"""Extrahiert die ZUSAMMENFASSUNG-Sektion und liefert sie als HTML mit verlinkten Zitaten."""
lines, remaining = _extract_zusammenfassung_lines(summary_text)
if not lines:
return "", summary_text
src_list = sources or []
html_lines = [f"<li>{_linkify_citations_html(line, src_list)}</li>" for line in lines]
html = "<ul>\n" + "\n".join(html_lines) + "\n</ul>"
return html, remaining
async def generate_executive_summary(summary_text: str) -> str:
"""KI-verdichtetes Executive Summary aus dem Lagebild."""
if not summary_text or len(summary_text.strip()) < 50:
return "<ul><li>Kein Lagebild verfügbar. Zusammenfassung kann nicht erstellt werden.</li></ul>"
from agents.claude_client import call_claude
prompt = f"""Du bist ein Intelligence-Analyst für ein OSINT-Lagemonitoring-System.
Verdichte das folgende Lagebild auf genau 3-5 Kernpunkte.
REGELN:
- Jeder Punkt: 1-2 Sätze, faktenbasiert
- Fokus: Was ist passiert? Was bedeutet es? Was ist die aktuelle Dynamik?
- Sprache: Deutsch, sachlich, prägnant
- Format: Gib NUR die Bullet Points aus, einen pro Zeile, mit "- " am Anfang
- KEINE Einleitung, KEINE Überschrift, NUR die Punkte
LAGEBILD:
{summary_text}"""
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
# Robuster Parser: Akzeptiert JSON, Markdown-Listen oder Freitext
lines = []
text = result.strip()
# Code-Fences entfernen (```json ... ```)
if text.startswith("```"):
text = re.sub(r"^```\w*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
# Fall 1: JSON-Antwort (Haiku gibt manchmal JSON zurück)
if text.startswith("{"):
try:
data = json.loads(text)
for key in data:
if isinstance(data[key], list):
for item in data[key]:
clean = str(item).strip().lstrip("- ").lstrip("* ")
if clean:
lines.append(clean)
break
except json.JSONDecodeError:
pass
# Fall 2: Markdown Bullet Points
if not lines:
for line in text.split("\n"):
stripped = line.strip()
if stripped.startswith(("- ", "* ")):
clean = stripped.lstrip("- ").lstrip("* ").strip()
if clean:
lines.append(clean)
# Fall 3: Nummerierte Liste (1. 2. 3.)
if not lines:
for line in text.split("\n"):
m = re.match(r"^\d+\.\s+(.+)", line.strip())
if m:
lines.append(m.group(1).strip())
# Fallback: Ganzen Text als einen Punkt
if not lines:
lines = [text[:500]]
html = "<ul>\n" + "\n".join(f"<li>{line}</li>" for line in lines if line) + "\n</ul>"
return html
except Exception as e:
logger.error(f"Executive Summary Generierung fehlgeschlagen: {e}")
return "<ul><li>Zusammenfassung konnte nicht generiert werden.</li></ul>"
def _parse_db_timestamp(value) -> datetime | None:
"""SQLite-Timestamp robust als datetime parsen (ISO oder 'YYYY-MM-DD HH:MM:SS')."""
if not value:
return None
if isinstance(value, datetime):
return value
try:
text = str(value).replace("T", " ").replace("Z", "")
# Sekundenbruchteile und Timezone-Offset abschneiden (python-docx mag nur naive dt)
text = text.split(".")[0].split("+")[0].strip()
return datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
try:
return datetime.strptime(str(value)[:10], "%Y-%m-%d")
except (ValueError, TypeError):
return None
def _slug_scope_label(scope: str, sections: set[str] | None) -> str:
"""Scope-Label fuer Metadaten und Dateinamen."""
if sections:
if sections == {"zusammenfassung"}:
return "Zusammenfassung"
if "timeline" in sections:
return "Vollständiger Bericht"
return "Lagebericht"
return {"summary": "Zusammenfassung", "report": "Lagebericht", "full": "Vollständiger Bericht"}.get(
scope, "Lagebericht"
)
def _build_export_metadata(
incident: dict,
articles: list,
fact_checks: list,
sources: list,
creator: str,
scope: str,
sections: set[str] | None,
organization_name: str | None,
top_locations: list[str] | None,
snapshot_count: int = 0,
) -> dict:
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties)."""
is_research = incident.get("type") == "research"
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
scope_label = _slug_scope_label(scope, sections)
title_raw = (incident.get("title") or "Unbenannte Lage").strip()
title = f"{title_raw}{type_label}"
subject = (incident.get("description") or "").strip()
if not subject:
subject = f"{type_label} zu: {title_raw}"
# Keywords sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
keywords: list[str] = ["OSINT", type_label]
if organization_name:
keywords.append(organization_name)
# category_labels: kann JSON-Dict (Karte primary/secondary/...), JSON-Liste
# oder ein Komma-getrennter String sein. Nur die Label-Werte extrahieren.
cat_labels_raw = (incident.get("category_labels") or "").strip()
if cat_labels_raw:
cat_values: list[str] = []
try:
parsed = json.loads(cat_labels_raw)
if isinstance(parsed, dict):
cat_values = [str(v).strip() for v in parsed.values() if isinstance(v, str) and v.strip()]
elif isinstance(parsed, list):
cat_values = [str(v).strip() for v in parsed if isinstance(v, str) and v.strip()]
except (json.JSONDecodeError, TypeError):
cat_values = [lbl.strip() for lbl in cat_labels_raw.split(",") if lbl.strip()]
# Keine JSON-Fragmente (geschweifte/eckige Klammern) als Keyword zulassen
for lbl in cat_values:
if lbl and not any(c in lbl for c in "{}[]"):
keywords.append(lbl)
if top_locations:
keywords.extend([loc for loc in top_locations if loc])
# Sanitize: Zeilenumbrueche/Tabs weg, Sonderzeichen mit PDF-Sonderbedeutung filtern
def _sanitize_keyword(kw: str) -> str:
if not kw:
return ""
# Whitespace normalisieren
cleaned = re.sub(r"\s+", " ", kw).strip()
# PDF-Dict/Array-Klammern und Backslash raus (WeasyPrint escaped () bei Strings,
# { und [ koennen aber den Keywords-Stream abschneiden)
cleaned = re.sub(r"[{}\[\]\\]", "", cleaned)
return cleaned.strip(" ,;:")
# Dedup (case-insensitive) mit Reihenfolge erhalten, max 15
seen = set()
unique_keywords: list[str] = []
for kw in keywords:
clean_kw = _sanitize_keyword(kw)
if not clean_kw:
continue
key = clean_kw.lower()
if key not in seen:
seen.add(key)
unique_keywords.append(clean_kw)
if len(unique_keywords) >= 15:
break
now = datetime.now(TIMEZONE)
created = _parse_db_timestamp(incident.get("created_at")) or now.replace(tzinfo=None)
modified = _parse_db_timestamp(incident.get("updated_at")) or created
# Strukturierter Comments-Block (wird in DOCX angezeigt, kompakt)
stand = now.strftime("%d.%m.%Y")
comments_lines = [
f"Incident-ID: {incident.get('id', '?')} | Typ: {incident.get('type', 'adhoc')} | Scope: {scope_label}",
f"Stand: {stand}",
]
if organization_name:
comments_lines.append(f"Organisation: {organization_name}")
comments_lines.append(
f"Umfang: {len(articles)} Artikel, {len(fact_checks)} Faktenchecks, {len(sources)} Quellen"
)
if top_locations:
comments_lines.append("Orte: " + ", ".join(top_locations[:5]))
comments = "\n".join(comments_lines)
publisher = organization_name or "AegisSight"
identifier = f"urn:aegissight:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
rights = (
"Vertrauliche Lageanalyse — AegisSight Monitor. "
"Weitergabe nur an autorisierte Empfänger."
)
return {
"title": title,
"author": creator or "AegisSight Monitor",
"subject": subject,
"keywords": unique_keywords,
"keywords_comma": ", ".join(unique_keywords),
"keywords_semicolon": "; ".join(unique_keywords),
"category": category,
"comments": comments,
"creator_app": "AegisSight Monitor",
"language": "de-DE",
"created": created,
"modified": modified,
"created_iso": created.strftime("%Y-%m-%dT%H:%M:%S"),
"modified_iso": modified.strftime("%Y-%m-%dT%H:%M:%S"),
"type_label": type_label,
"scope_label": scope_label,
"publisher": publisher,
"identifier": identifier,
"rights": rights,
"doc_type": "Report",
"version_id": str(max(1, snapshot_count)),
}
def _format_pdf_date(dt: datetime) -> str:
"""PDF-Datumsformat: D:YYYYMMDDHHmmSS+HH'mm' (mit Zeitzone) oder Z (UTC)."""
if dt.tzinfo is None:
# Naive dt — als lokale TIMEZONE interpretieren
dt = dt.replace(tzinfo=TIMEZONE)
base = dt.strftime("D:%Y%m%d%H%M%S")
offset = dt.utcoffset()
if offset is None:
return base + "Z"
total_minutes = int(offset.total_seconds() // 60)
sign = "+" if total_minutes >= 0 else "-"
total_minutes = abs(total_minutes)
return f"{base}{sign}{total_minutes // 60:02d}'{total_minutes % 60:02d}'"
def _enrich_pdf_metadata(pdf_bytes: bytes, meta: dict) -> bytes:
"""PDF-Ausgabe um XMP-Metadaten und CreationDate/ModDate erweitern (post-process via pikepdf)."""
try:
buf_in = io.BytesIO(pdf_bytes)
with pikepdf.Pdf.open(buf_in) as pdf:
created: datetime = meta.get("created")
modified: datetime = meta.get("modified")
if created and created.tzinfo is None:
created = created.replace(tzinfo=TIMEZONE)
if modified and modified.tzinfo is None:
modified = modified.replace(tzinfo=TIMEZONE)
# Klassisches Info-Dict: CreationDate + ModDate nachziehen
if created:
pdf.docinfo["/CreationDate"] = pikepdf.String(_format_pdf_date(created))
if modified:
pdf.docinfo["/ModDate"] = pikepdf.String(_format_pdf_date(modified))
# Document-/Instance-ID fuer DMS-Versionierung (frisch pro Export)
doc_uuid = f"uuid:{uuid.uuid4()}"
instance_uuid = f"uuid:{uuid.uuid4()}"
# XMP-Metadatenblock schreiben (Dublin Core + XMP + PDF + xmpRights + xmpMM)
with pdf.open_metadata(set_pikepdf_as_editor=False) as xmp:
# Dublin Core
xmp["dc:title"] = meta.get("title", "")
xmp["dc:creator"] = [meta.get("author", "")]
xmp["dc:description"] = meta.get("subject", "")
if meta.get("keywords"):
xmp["dc:subject"] = list(meta["keywords"])
xmp["dc:language"] = [meta.get("language", "de-DE")]
xmp["dc:publisher"] = [meta.get("publisher", "AegisSight")]
xmp["dc:identifier"] = meta.get("identifier", "")
xmp["dc:format"] = "application/pdf"
xmp["dc:type"] = [meta.get("doc_type", "Report")]
xmp["dc:rights"] = meta.get("rights", "")
if created:
xmp["dc:date"] = [created.strftime("%Y-%m-%dT%H:%M:%S%z")]
# PDF Namespace
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
xmp["pdf:Producer"] = "WeasyPrint + AegisSight Monitor"
# XMP Namespace
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
if created:
xmp["xmp:CreateDate"] = created.strftime("%Y-%m-%dT%H:%M:%S%z")
if modified:
xmp["xmp:ModifyDate"] = modified.strftime("%Y-%m-%dT%H:%M:%S%z")
xmp["xmp:MetadataDate"] = modified.strftime("%Y-%m-%dT%H:%M:%S%z")
# xmpRights: Rechte- und Vertraulichkeitshinweis (XMP erwartet String "True")
xmp["xmpRights:Marked"] = "True"
if meta.get("rights"):
# String: pikepdf wrapped das automatisch als LangAlt mit x-default
xmp["xmpRights:UsageTerms"] = meta["rights"]
# xmpMM: Document- und Instance-ID fuer DMS-Versionierung
xmp["xmpMM:DocumentID"] = doc_uuid
xmp["xmpMM:InstanceID"] = instance_uuid
xmp["xmpMM:VersionID"] = meta.get("version_id", "1")
# xmpMM:History — Audit-Event fuer diesen Export (einzeiliger Eintrag je Seq-Item)
history_when = (modified or datetime.now(TIMEZONE)).strftime("%Y-%m-%dT%H:%M:%S%z")
history_entry = (
f"action=published; when={history_when}; "
f"softwareAgent={meta.get('creator_app', 'AegisSight Monitor')}; "
f"instanceID={instance_uuid}; "
f"scope={meta.get('scope_label', '')}; "
f"version={meta.get('version_id', '1')}"
)
xmp["xmpMM:History"] = [history_entry]
buf_out = io.BytesIO()
pdf.save(buf_out)
return buf_out.getvalue()
except Exception as e:
logger.warning(f"PDF-Metadaten-Anreicherung (XMP/Dates) fehlgeschlagen: {e}")
return pdf_bytes
async def generate_pdf(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, creator: str, executive_summary_html: str,
sections: set[str] | None = None,
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
# Sections aus scope ableiten wenn nicht explizit angegeben
if sections is None:
if scope == "summary":
sections = {"zusammenfassung"}
elif scope == "report":
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen"}
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
zusammenfassung_html = executive_summary_html
bericht_summary = incident.get("summary", "")
zusammenfassung_title = "Zusammenfassung"
if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
if extracted_html:
zusammenfassung_html = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
# Auch das (nicht-research) Executive Summary linkifizieren — ggf. enthaelt es Zitate
if not is_research and zusammenfassung_html:
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
)
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
template = env.get_template("report.html")
now = datetime.now(TIMEZONE)
incident_type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
html_content = template.render(
incident=incident,
incident_type_label=incident_type_label,
report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
creator=creator,
logo_base64=_get_logo_base64(),
executive_summary=zusammenfassung_html,
zusammenfassung_title=zusammenfassung_title,
sections=sections,
scope=scope,
lagebild_html=_linkify_citations_html(
_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),
fact_checks=_prepare_fact_checks(fact_checks),
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],
meta=meta,
)
# Artikel pub_date aufbereiten
for art in articles:
pub = str(art.get("published_at") or art.get("collected_at") or "")
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
art["pub_date"] = dt.strftime("%d.%m.%Y")
except Exception:
art["pub_date"] = pub[:10] if pub else ""
pdf_bytes = HTML(string=html_content).write_pdf()
pdf_bytes = _enrich_pdf_metadata(pdf_bytes, meta)
return pdf_bytes
async def generate_docx(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, creator: str, executive_summary_text: str,
sections: set[str] | None = None,
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
# Sections aus scope ableiten wenn nicht explizit angegeben
if sections is None:
if scope == "summary":
sections = {"zusammenfassung"}
elif scope == "report":
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen"}
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = []
if is_research and bericht_summary:
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
if extracted_lines:
zusammenfassung_lines = extracted_lines
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
)
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
cp = doc.core_properties
cp.title = meta["title"]
cp.author = meta["author"]
cp.subject = meta["subject"]
cp.keywords = meta["keywords_semicolon"]
cp.comments = meta["comments"]
cp.category = meta["category"]
cp.last_modified_by = meta["author"]
cp.language = meta["language"]
cp.content_status = "Final"
try:
cp.created = meta["created"]
cp.modified = meta["modified"]
except (ValueError, TypeError) as e:
logger.warning(f"DOCX created/modified konnte nicht gesetzt werden: {e}")
# Styles
style = doc.styles['Normal']
style.font.size = Pt(10)
style.font.name = 'Calibri'
# --- Deckblatt ---
for _ in range(6):
doc.add_paragraph()
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
doc.add_paragraph()
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
type_para = doc.add_paragraph()
type_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = type_para.add_run(type_label)
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
title_para2 = doc.add_paragraph()
title_para2.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para2.add_run(incident.get("title", ""))
run.font.size = Pt(24)
run.font.bold = True
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
if incident.get("description"):
desc_para = doc.add_paragraph()
desc_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = desc_para.add_run(incident["description"])
run.font.size = Pt(11)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_paragraph()
for _ in range(3):
doc.add_paragraph()
now = datetime.now(TIMEZONE)
meta_para = doc.add_paragraph()
meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = meta_para.add_run(f"Stand: {now.strftime('%d.%m.%Y, %H:%M Uhr')}\nErstellt von: {creator}")
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
doc.add_page_break()
# --- Zusammenfassung / Executive Summary ---
if "zusammenfassung" in sections:
doc.add_heading(zusammenfassung_title, level=1)
if zusammenfassung_lines:
for line in zusammenfassung_lines:
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
else:
# Fallback: HTML-Tags aus executive_summary_text strippen, dann Bullets bilden
clean_text = re.sub(r'<[^>]+>', '', zusammenfassung_text or '')
lines = [line.strip().lstrip("- ").lstrip("* ") for line in clean_text.strip().split("\n") if line.strip()]
for line in lines:
if line:
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
if "bericht" in sections:
# --- Lagebild / Recherchebericht ---
doc.add_heading("Recherchebericht" if is_research else "Lagebild", level=1)
# Markdown-Formatierung entfernen, Zitate aber als [NNN] beibehalten und als Hyperlinks rendern
clean_summary = re.sub(r'\*\*(.+?)\*\*', r'\1', bericht_summary)
clean_summary = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', clean_summary)
clean_summary = re.sub(r'^#{1,3}\s+', '', clean_summary, flags=re.MULTILINE)
for para_text in clean_summary.split("\n\n"):
para_text = para_text.strip()
if not para_text:
continue
if para_text.startswith("- "):
for bullet in para_text.split("\n"):
bullet = bullet.lstrip("- ").strip()
if bullet:
_add_docx_paragraph_with_citations(doc, bullet, all_sources, style='List Bullet')
else:
_add_docx_paragraph_with_citations(doc, para_text, all_sources)
if "faktencheck" in sections:
# --- Faktencheck ---
report_fcs = fact_checks
if report_fcs:
doc.add_heading("Faktencheck", 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 = "Behauptung"
hdr[1].text = "Status"
hdr[2].text = "Quellen"
for cell in hdr:
for p in cell.paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(9)
for fc in report_fcs:
row = table.add_row().cells
row[0].text = fc.get("claim", "")
row[1].text = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", ""))
row[2].text = str(fc.get("sources_count", 0))
if "quellen" in sections:
# --- Quellenstatistik ---
source_stats = _prepare_source_stats(articles)
if source_stats:
doc.add_heading("Quellenstatistik", 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 = "Quelle"
hdr[1].text = "Artikel"
hdr[2].text = "Sprache"
for cell in hdr:
for p in cell.paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(9)
for stat in source_stats:
row = table.add_row().cells
row[0].text = stat["name"]
row[1].text = str(stat["count"])
row[2].text = stat["languages"]
if "timeline" in sections:
# --- Artikelverzeichnis ---
if articles:
doc.add_page_break()
doc.add_heading(f"Artikelverzeichnis ({len(articles)} Artikel)", level=1)
table = doc.add_table(rows=1, cols=4)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
for i, txt in enumerate(["Headline", "Quelle", "Sprache", "Datum"]):
hdr[i].text = txt
for p in hdr[i].paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(8)
for art in articles:
row = table.add_row().cells
row[0].text = art.get("headline_de") or art.get("headline") or "Ohne Titel"
row[1].text = art.get("source") or ""
row[2].text = (art.get("language") or "de").upper()
pub = str(art.get("published_at") or art.get("collected_at") or "")
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
row[3].text = dt.strftime("%d.%m.%Y")
except Exception:
row[3].text = pub[:10] if pub else ""
# Schriftgröße reduzieren
for cell in row:
for p in cell.paragraphs:
for run in p.runs:
run.font.size = Pt(8)
# --- Footer ---
doc.add_paragraph()
footer = doc.add_paragraph()
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()

Datei anzeigen

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="{{ meta.language if meta else 'de-DE' }}">
<head>
<meta charset="UTF-8">
{% if meta %}
<title>{{ meta.title }}</title>
<meta name="author" content="{{ meta.author }}">
<meta name="description" content="{{ meta.subject }}">
<meta name="keywords" content="{{ meta.keywords_comma }}">
<meta name="subject" content="{{ meta.subject }}">
<meta name="generator" content="{{ meta.creator_app }}">
<meta name="dcterms.created" content="{{ meta.created_iso }}">
<meta name="dcterms.modified" content="{{ meta.modified_iso }}">
{% else %}
<title>{{ incident.title }}</title>
{% endif %}
<style>
@page { margin: 20mm 18mm 20mm 18mm; size: A4; @bottom-center { content: "Seite " counter(page) " von " counter(pages); font-size: 8pt; color: #0a1832; } }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.55; color: #1a1a1a; }
/* Deckblatt */
.cover { page-break-after: always; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 85vh; text-align: center; }
.cover-logo { width: 80px; height: auto; margin-bottom: 30px; }
.cover-title { font-size: 26pt; font-weight: 700; color: #0a1832; margin-bottom: 8px; }
.cover-subtitle { font-size: 12pt; color: #666; margin-bottom: 40px; }
.cover-type { font-size: 10pt; color: #0a1832; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 6px; }
.cover-meta { font-size: 9pt; color: #0a1832; margin-top: 40px; }
.cover-meta div { margin-bottom: 3px; }
.cover-brand { font-size: 9pt; color: #0a1832; margin-top: 50px; letter-spacing: 1px; }
/* Inhaltsverzeichnis */
.toc { page-break-after: always; padding-top: 40px; }
.toc h2 { font-size: 16pt; font-weight: 700; color: #0a1832; border-bottom: 2px solid #c8a851; padding-bottom: 6px; margin-bottom: 24px; }
.toc-list { list-style: none; padding: 0; margin: 0; counter-reset: toc-counter; }
.toc-list li { padding: 10px 0; border-bottom: 1px solid #e0e0e0; counter-increment: toc-counter; }
.toc-list li::before { content: counter(toc-counter) "."; display: inline-block; width: 24px; font-weight: 600; color: #0a1832; }
.toc-list a { color: #0a1832; text-decoration: none; font-size: 11pt; }
/* Sections */
.section { page-break-before: always; margin-bottom: 20px; }
.section h2 { font-size: 14pt; font-weight: 700; color: #0a1832; border-bottom: 2px solid #c8a851; padding-bottom: 4px; margin-bottom: 12px; }
.section h3 { font-size: 11pt; font-weight: 600; color: #0a1832; margin: 14px 0 6px; }
/* Executive Summary */
.exec-summary { background: #f8f9fa; border-left: 4px solid #c8a851; padding: 16px 20px; margin-bottom: 20px; }
.exec-summary ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
/* Lagebild */
.lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; }
.lagebild-content strong { font-weight: 600; }
.lagebild-content a { color: #1a5276; text-decoration: underline; }
.lagebild-content ul, .lagebild-content ol { margin: 6px 0 6px 20px; }
.lagebild-content li { margin-bottom: 3px; }
/* Tabellen */
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
.quellen-table { table-layout: fixed; font-size: 8pt; }
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; }
/* Faktencheck */
.fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 2px 8px; border-radius: 3px; }
.fc-confirmed { background: #d4edda; color: #155724; }
.fc-disputed { background: #f8d7da; color: #721c24; }
.fc-unconfirmed { background: #fff3cd; color: #856404; }
/* Timeline */
.tl-item { padding: 4px 0; border-left: 2px solid #c8a851; padding-left: 12px; margin-bottom: 6px; }
.tl-date { font-size: 8.5pt; color: #0a1832; }
.tl-title { font-size: 10pt; }
.tl-source { font-size: 8pt; color: #0a1832; }
/* Quellenverzeichnis */
.source-ref { font-size: 7pt; color: #666; word-break: break-all; max-width: 350px; overflow: hidden; text-overflow: ellipsis; }
/* Footer */
.report-footer { margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #0a1832; text-align: center; }
</style>
</head>
<body>
<!-- Deckblatt -->
<div class="cover">
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-meta">
<div>Stand: {{ report_date }}</div>
<div>Erstellt von: {{ creator }}</div>
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
</div>
<div class="cover-brand">AegisSight Monitor</div>
</div>
<!-- Inhaltsverzeichnis -->
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
<ul class="toc-list">
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">Zusammenfassung</a></li>{% endif %}
{% if 'bericht' in sections %}<li><a href="#sec-bericht">{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</a></li>{% endif %}
{% if 'faktencheck' in sections and fact_checks %}<li><a href="#sec-faktencheck">Faktencheck</a></li>{% endif %}
{% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}
{% if 'timeline' in sections and timeline %}<li><a href="#sec-timeline">Ereignis-Timeline</a></li>{% endif %}
{% if 'timeline' in sections and articles %}<li><a href="#sec-artikel">Artikelverzeichnis</a></li>{% endif %}
</ul>
</div>
<!-- Zusammenfassung -->
{% if 'zusammenfassung' in sections %}
<div class="section" id="sec-zusammenfassung">
<h2>{{ zusammenfassung_title }}</h2>
<div class="exec-summary">
{{ executive_summary | safe }}
</div>
</div>
{% endif %}
<!-- Recherchebericht / Lagebild -->
{% if 'bericht' in sections %}
<div class="section" id="sec-bericht">
<h2>{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</h2>
{% if lagebild_timestamp %}<p style="font-size:9pt;color:#0a1832;margin-bottom:10px;">Aktualisiert: {{ lagebild_timestamp }}</p>{% endif %}
<div class="lagebild-content">{{ lagebild_html | safe }}</div>
</div>
{% endif %}
<!-- Faktencheck -->
{% if 'faktencheck' in sections and fact_checks %}
<div class="section" id="sec-faktencheck">
<h2>Faktencheck</h2>
<table>
<thead><tr><th>Behauptung</th><th>Status</th><th>Quellen</th></tr></thead>
<tbody>
{% for fc in fact_checks %}
<tr>
<td>{{ fc.claim or '' }}</td>
<td><span class="fc-badge fc-{{ fc.status or 'unconfirmed' }}">{{ fc.status_label }}</span></td>
<td>{{ fc.sources_count or 0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Quellenverzeichnis -->
{% if 'quellen' in sections and sources %}
<div class="section" id="sec-quellen">
<h2>Quellenverzeichnis</h2>
{% if source_stats %}
<h3>Quellenstatistik</h3>
<table>
<thead><tr><th>Quelle</th><th>Artikel</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>
{% 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>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Timeline -->
{% if 'timeline' in sections and timeline %}
<div class="section" id="sec-timeline">
<h2>Ereignis-Timeline</h2>
{% for event in timeline %}
<div class="tl-item">
<div class="tl-date">{{ event.date }}</div>
<div class="tl-title">{{ event.headline }}</div>
<div class="tl-source">{{ event.source }}</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Artikelverzeichnis -->
{% if 'timeline' in sections and articles %}
<div class="section" id="sec-artikel">
<h2>Artikelverzeichnis ({{ articles | length }} Artikel)</h2>
<table>
<thead><tr><th>Headline</th><th>Quelle</th><th>Sprache</th><th>Datum</th></tr></thead>
<tbody>
{% for art in articles %}
<tr>
<td>{{ art.headline_de or art.headline or 'Ohne Titel' }}</td>
<td>{{ art.source or '' }}</td>
<td>{{ (art.language or 'de') | upper }}</td>
<td>{{ art.pub_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}
</div>
</body>
</html>

Datei anzeigen

@@ -1,12 +1,17 @@
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
def _staging_mode() -> bool:
"""STAGING_MODE Env-Flag (vgl. services.license_service)."""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
from models import (
MagicLinkRequest,
MagicLinkResponse,
VerifyTokenRequest,
VerifyCodeRequest,
TokenResponse,
UserMeResponse,
)
@@ -14,13 +19,12 @@ from auth import (
create_token,
get_current_user,
generate_magic_token,
generate_magic_code,
)
from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
from email_utils.sender import send_email
from email_utils.templates import magic_link_login_email
from email_utils.rate_limiter import magic_link_limiter, verify_code_limiter
from email_utils.rate_limiter import magic_link_limiter
import aiosqlite
logger = logging.getLogger("osint.auth")
@@ -34,15 +38,14 @@ async def request_magic_link(
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Link anfordern. Sendet E-Mail mit Link + Code."""
"""Magic Link anfordern. Sendet E-Mail mit Link."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Rate-Limit pruefen
# Rate-Limit prüfen
allowed, reason = magic_link_limiter.check(email, ip)
if not allowed:
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
# Trotzdem 200 zurueckgeben (kein Information-Leak)
logger.warning(f"Rate-Limit für {email} von {ip}: {reason}")
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Nutzer suchen
@@ -68,19 +71,18 @@ async def request_magic_link(
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Lizenz pruefen
# Lizenz prüfen
from services.license_service import check_license
lic = await check_license(db, user["organization_id"])
if lic.get("status") == "org_disabled":
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Token + Code generieren
# Token generieren
token = generate_magic_token()
code = generate_magic_code()
expires_at = (datetime.now(TIMEZONE) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
# Alte ungenutzte Magic Links für diese E-Mail invalidieren
await db.execute(
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
(email,),
@@ -89,14 +91,14 @@ async def request_magic_link(
# Neuen Magic Link speichern
await db.execute(
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
(email, token, code, user["id"], expires_at, ip),
VALUES (?, ?, '', 'login', ?, ?, ?)""",
(email, token, user["id"], expires_at, ip),
)
await db.commit()
# E-Mail senden
link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
subject, html = magic_link_login_email(user["email"].split("@")[0], code, link)
subject, html = magic_link_login_email(user["email"].split("@")[0], link)
await send_email(email, subject, html)
magic_link_limiter.record(email, ip)
@@ -121,9 +123,9 @@ async def verify_magic_link(
ml = await cursor.fetchone()
if not ml:
raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link")
raise HTTPException(status_code=400, detail="Ungültiger oder bereits verwendeter Link")
# Ablauf pruefen
# Ablauf prüfen
now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
@@ -144,6 +146,13 @@ async def verify_magic_link(
)
await db.commit()
# Global-Admin-Flag aus DB lesen
ga_cursor = await db.execute(
"SELECT is_global_admin FROM users WHERE id = ?", (ml["user_id"],)
)
ga_row = await ga_cursor.fetchone()
_is_global_admin = bool(ga_row["is_global_admin"]) if ga_row else False
# JWT erstellen
token = create_token(
user_id=ml["user_id"],
@@ -152,84 +161,7 @@ async def verify_magic_link(
role=ml["role"],
tenant_id=ml["organization_id"],
org_slug=ml["org_slug"],
)
return TokenResponse(
access_token=token,
username=ml["username"],
)
@router.post("/verify-code", response_model=TokenResponse)
async def verify_magic_code(
data: VerifyCodeRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Code verifizieren (6-stelliger Code + E-Mail)."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Brute-Force-Schutz: Fehlversuche pruefen
allowed, reason = verify_code_limiter.check(email, ip)
if not allowed:
logger.warning(f"Verify-Code Rate-Limit fuer {email} von {ip}: {reason}")
# Bei Sperre alle offenen Magic Links fuer diese E-Mail invalidieren
await db.execute(
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
(email,),
)
await db.commit()
raise HTTPException(status_code=429, detail=reason)
cursor = await db.execute(
"""SELECT ml.*, u.username, u.email as user_email, u.role, u.organization_id, u.is_active,
o.slug as org_slug, o.is_active as org_active
FROM magic_links ml
JOIN users u ON u.id = ml.user_id
JOIN organizations o ON o.id = u.organization_id
WHERE LOWER(ml.email) = ? AND ml.code = ? AND ml.is_used = 0
ORDER BY ml.created_at DESC LIMIT 1""",
(email, data.code),
)
ml = await cursor.fetchone()
if not ml:
verify_code_limiter.record_failure(email, ip)
logger.warning(f"Fehlgeschlagener Code-Versuch fuer {email} von {ip}")
raise HTTPException(status_code=400, detail="Ungueltiger Code")
# Ablauf pruefen
now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=TIMEZONE)
if now > expires:
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")
if not ml["is_active"] or not ml["org_active"]:
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
# Magic Link als verwendet markieren
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
# Letzten Login aktualisieren
await db.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?",
(now.isoformat(), ml["user_id"]),
)
await db.commit()
# Fehlversuche-Zaehler nach Erfolg zuruecksetzen
verify_code_limiter.clear(email)
token = create_token(
user_id=ml["user_id"],
username=ml["username"],
email=ml["user_email"],
role=ml["role"],
tenant_id=ml["organization_id"],
org_slug=ml["org_slug"],
is_global_admin=_is_global_admin,
)
return TokenResponse(
@@ -261,10 +193,11 @@ async def get_me(
from services.license_service import check_license
license_info = await check_license(db, current_user["tenant_id"])
# Credits-Daten laden
# Credits-Daten laden (echte Prozente, nicht gekappt)
credits_total = None
credits_remaining = None
credits_percent_used = None
unlimited_budget = bool(license_info.get("unlimited_budget", False))
if current_user.get("tenant_id"):
lic_cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
@@ -274,7 +207,12 @@ async def get_me(
credits_total = lic_row["credits_total"]
credits_used = lic_row["credits_used"] or 0
credits_remaining = max(0, int(credits_total - credits_used))
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
# STAGING_MODE: Org-Switcher im Frontend deaktivieren
is_global_admin_response = current_user.get("is_global_admin", False)
if _staging_mode():
is_global_admin_response = False
return UserMeResponse(
id=current_user["id"],
@@ -290,4 +228,65 @@ async def get_me(
license_status=license_info.get("status", "unknown"),
license_type=license_info.get("license_type", ""),
read_only=license_info.get("read_only", False),
read_only_reason=license_info.get("read_only_reason"),
unlimited_budget=unlimited_budget,
is_global_admin=is_global_admin_response,
)
# --- Global Admin: Org-Wechsel (herausnehmbar) ---
from models import SwitchOrgRequest, OrgListItem
@router.get("/organizations")
async def list_all_organizations(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Organisationen auflisten (nur fuer Global Admin)."""
if not current_user.get("is_global_admin"):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
cursor = await db.execute(
"SELECT id, name, slug, is_active FROM organizations ORDER BY name"
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.post("/switch-org")
async def switch_organization(
data: SwitchOrgRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Organisation wechseln (nur fuer Global Admin). Gibt neues JWT zurueck."""
if not current_user.get("is_global_admin"):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
# Ziel-Org pruefen
cursor = await db.execute(
"SELECT id, name, slug FROM organizations WHERE id = ?", (data.organization_id,)
)
org = await cursor.fetchone()
if not org:
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Neues JWT mit anderem tenant_id ausstellen
token = create_token(
user_id=current_user["id"],
username=current_user["username"],
email=current_user["email"],
role=current_user["role"],
tenant_id=org["id"],
org_slug=org["slug"],
is_global_admin=True,
)
return {
"access_token": token,
"token_type": "bearer",
"org_name": org["name"],
"org_slug": org["slug"],
}

Datei anzeigen

@@ -12,6 +12,11 @@ from pydantic import BaseModel, Field
from auth import get_current_user
from config import CLAUDE_PATH, CLAUDE_MODEL_FAST
from database import db_dependency
from middleware.license_check import require_writable_license
from services.license_service import charge_usage_to_tenant
from agents.claude_client import ClaudeUsage, ClaudeCliError, _classify_cli_error
import aiosqlite
logger = logging.getLogger("osint.chat")
@@ -21,8 +26,8 @@ router = APIRouter(tags=["chat"])
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
# ---------------------------------------------------------------------------
async def _call_claude_chat(prompt: str) -> tuple[str, int]:
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms) zurueck.
async def _call_claude_chat(prompt: str) -> tuple[str, int, ClaudeUsage]:
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms, usage) zurueck.
Anders als call_claude(): kein JSON-Output-Modus, kein append-system-prompt.
"""
@@ -46,7 +51,7 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]:
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")), timeout=60
process.communicate(input=prompt.encode("utf-8")), timeout=120
)
except asyncio.TimeoutError:
process.kill()
@@ -54,29 +59,44 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]:
if process.returncode != 0:
err_msg = stderr.decode("utf-8", errors="replace").strip()
logger.error(f"Chat Claude CLI Fehler (rc={process.returncode}): {err_msg[:500]}")
if "rate_limit" in err_msg.lower() or "overloaded" in err_msg.lower():
raise RuntimeError("rate_limit")
raise RuntimeError(f"Claude CLI Fehler: {err_msg[:200]}")
stdout_msg = stdout.decode("utf-8", errors="replace").strip()
combined = f"{err_msg} {stdout_msg}"
error_type = _classify_cli_error(combined)
logger.error(f"Chat Claude CLI Fehler [{error_type}] (rc={process.returncode}): {(stdout_msg or err_msg)[:500]}")
raise ClaudeCliError(error_type, stdout_msg or err_msg)
raw = stdout.decode("utf-8", errors="replace").strip()
duration_ms = 0
result_text = raw
usage = ClaudeUsage()
try:
data = _json.loads(raw)
if data.get("is_error"):
error_text = str(data.get("result", ""))
error_type = _classify_cli_error(error_text)
logger.error(f"Chat Claude CLI Fehler [{error_type}] (is_error): {error_text[:500]}")
raise ClaudeCliError(error_type, error_text)
result_text = data.get("result", raw)
duration_ms = data.get("duration_ms", 0)
cost = data.get("total_cost_usd", 0.0)
u = data.get("usage", {})
usage = ClaudeUsage(
input_tokens=u.get("input_tokens", 0),
output_tokens=u.get("output_tokens", 0),
cache_creation_tokens=u.get("cache_creation_input_tokens", 0),
cache_read_tokens=u.get("cache_read_input_tokens", 0),
cost_usd=data.get("total_cost_usd", 0.0),
duration_ms=duration_ms,
)
logger.info(
f"Chat Claude: {u.get('input_tokens', 0)} in / {u.get('output_tokens', 0)} out / "
f"${cost:.4f} / {duration_ms}ms"
f"Chat Claude: {usage.input_tokens} in / {usage.output_tokens} out / "
f"${usage.cost_usd:.4f} / {duration_ms}ms"
)
except _json.JSONDecodeError:
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
return result_text, duration_ms
return result_text, duration_ms, usage
# ---------------------------------------------------------------------------
# Models
@@ -278,12 +298,15 @@ STRENGE REGELN:
11. Du kennst NUR den AegisSight Monitor (das Dashboard). Du weisst NICHTS ueber andere Systeme, Verwaltungstools, Admin-Portale, interne Tools oder sonstige Komponenten. Wenn danach gefragt wird, gehe NICHT darauf ein, wiederhole den Begriff NICHT und sage NICHT "dazu kann ich keine Auskunft geben" (das impliziert Existenz). Ignoriere den Teil der Frage komplett und beantworte nur den Teil der sich auf den Monitor bezieht. Falls die gesamte Frage ausserhalb deines Bereichs liegt, sage einfach: "Ich helfe dir gerne bei Fragen zur Bedienung des AegisSight Monitors."
12. Wenn der Nutzer nach konkreten Lage-Inhalten, Artikeln oder Statistiken fragt, erklaere ihm freundlich wo er diese Informationen im Dashboard selbst finden kann. Du hast keinen Einblick in die Inhalte der Lagen und der Support ebenfalls nicht. Fuer technische Probleme mit der Anwendung kann sich der Nutzer an support@aegis-sight.de wenden.
AKTUELLE UI-BEZEICHNUNGEN (immer verwenden!):
Die zwei Lage-Typen heissen im Auswahlfeld: "Live-Monitoring, Ereignis beobachten" und "Recherche, Thema analysieren". Verwende NIEMALS die veraltete Bezeichnung "Ad-hoc Lage" oder "Ad-hoc". In der Sidebar heissen die Sektionen "Live-Monitoring" und "Recherchen". Der Typ-Badge zeigt "Live" bzw. "Analyse". Die Zusammenfassungs-Kachel heisst bei Live-Monitoring "Lagebild" und bei Recherche-Lagen "Recherchebericht". Der Button zum Anlegen heisst "Lage anlegen", nicht "Erstellen".
DEINE KERNAUFGABE:
Du bist eine interaktive Anleitung. Erklaere Schritt fuer Schritt wie der Monitor funktioniert. Fuehre den Nutzer durch die Oberflaeche und hilf ihm, alle Funktionen zu verstehen und effektiv zu nutzen.
Typische Fragen die du beantworten kannst:
- Wie erstelle ich eine neue Lage?
- Was ist der Unterschied zwischen Ad-hoc und Recherche?
- Was ist der Unterschied zwischen Live-Monitoring und Recherche?
- Wie funktioniert der automatische Refresh?
- Wie exportiere ich einen Lagebericht?
- Was bedeuten die Faktencheck-Status?
@@ -295,7 +318,9 @@ Typische Fragen die du beantworten kannst:
FEATURE-DOKUMENTATION:
Lage/Recherche erstellen:
Oben im Dashboard gibt es den Button "Neue Lage". Dort waehlt der Nutzer zwischen zwei Typen. "Ad-hoc Lage" eignet sich fuer schnelle Lageerfassung zu einem aktuellen Ereignis, hier reicht eine kurze, praegnante Beschreibung. "Recherche" ist fuer tiefergehende Analysen gedacht, hier sollte eine ausfuehrlichere Beschreibung mit Kontext, Zeitraum und Fokus eingegeben werden, das System nutzt dann KI-gestuetzte Quellenauswahl und eine breitere Suche. Bei beiden Typen gibt der Nutzer Titel und Beschreibung ein und klickt "Erstellen". Der erste Refresh startet automatisch und sammelt passende Artikel.
Oben im Dashboard gibt es den Button "Neue Lage". Dort waehlt der Nutzer unter "Art der Lage" zwischen zwei Typen. "Live-Monitoring, Ereignis beobachten" eignet sich fuer aktuelle Ereignisse, die der Nutzer laufend verfolgen moechte, hier reicht eine kurze, praegnante Beschreibung. Empfohlen ist die automatische Aktualisierung. "Recherche, Thema analysieren" ist fuer tiefergehende Analysen gedacht, hier sollte eine ausfuehrlichere Beschreibung mit Kontext, Zeitraum und Fokus eingegeben werden. Empfohlen ist manuelles Starten und bei Bedarf vertiefen. Bei beiden Typen gibt der Nutzer Titel und Beschreibung ein und klickt "Lage anlegen". Nach dem Anlegen startet die erste Aktualisierung automatisch. In der Sidebar werden Live-Monitoring Lagen unter "Live-Monitoring" und Recherchen unter "Recherchen" gruppiert angezeigt.
Wichtiger Unterschied bei Kacheln: Bei Live-Monitoring heisst die Zusammenfassungs-Kachel "Lagebild", bei Recherche-Lagen heisst sie "Recherchebericht". Auch im PDF-Export, in den Layout-Toggles und bei E-Mail-Benachrichtigungen passt sich die Bezeichnung entsprechend an.
Tipps fuer gute Lagebeschreibungen:
Je praeziser die Beschreibung, desto relevantere Ergebnisse liefert das System. Wichtige Aspekte sind: Geografischer Fokus (z.B. "Naher Osten", "Ukraine"), beteiligte Akteure (z.B. "NATO, Russland"), Zeitrahmen (z.B. "seit Februar 2026"), thematischer Schwerpunkt (z.B. "Waffenlieferungen, Diplomatie"). Fachbegriffe und alternative Schreibweisen erhoehen die Trefferquote.
@@ -303,17 +328,17 @@ Je praeziser die Beschreibung, desto relevantere Ergebnisse liefert das System.
Quellen:
Quellen werden automatisch vom System verwaltet. Es gibt verschiedene Kategorien: oeffentlich-rechtlich, Qualitaetszeitung, Nachrichtenagentur, international, Behoerde, Telegram und sonstige. Unter den Quellen-Einstellungen koennen bestimmte Domains blockiert werden, damit deren Artikel nicht mehr in Lagen erscheinen. Das System schlaegt auch automatisch neue relevante Quellen vor basierend auf den Themen der Lagen. Die Quellenansicht zeigt fuer jede Quelle Name, Kategorie, Typ, Artikelanzahl und wann zuletzt Artikel gefunden wurden.
Refresh-Modi:
Jede Lage hat einen Refresh-Modus. "Manuell" bedeutet, der Nutzer klickt selbst auf "Aktualisieren" wenn er neue Artikel suchen moechte. "Automatisch" laesst das System in einem einstellbaren Intervall automatisch nach neuen Artikeln suchen. Das Intervall ist pro Lage einstellbar, z.B. alle 15, 30, 60 oder 180 Minuten. Bei einem Refresh durchsucht das System alle konfigurierten Quellen nach neuen relevanten Artikeln, erstellt oder aktualisiert die Zusammenfassung und fuehrt Faktenchecks durch.
Aktualisierungs-Modi:
Jede Lage hat einen Aktualisierungs-Modus. "Manuell" bedeutet, der Nutzer klickt selbst auf "Aktualisieren" wenn er neue Artikel suchen moechte. "Automatisch" laesst die Lage in einem selbst gewaehlten Intervall turnusmaessig nach neuen Artikeln suchen. Das Intervall kann in Minuten, Stunden, Tagen oder Wochen angegeben werden, mindestens 10 Minuten. Im Automatik-Modus laesst sich ausserdem eine Uhrzeit fuer die erste Aktualisierung festlegen, danach laeuft es im gewaehlten Takt weiter. Bei jeder Aktualisierung kommen neue Artikel hinzu, die Zusammenfassung wird aktualisiert und die Faktenchecks werden neu bewertet.
Faktenchecks:
Das System prueft automatisch Behauptungen aus den gesammelten Artikeln. Es gibt vier Status: "Bestaetigt" bedeutet mehrere unabhaengige Quellen bestaetigen die Information. "Umstritten" heisst Quellen widersprechen sich und die Faktenlage ist unklar. "Widerlegt" bedeutet die Information wurde durch zuverlaessige Quellen widerlegt. "In Entwicklung" zeigt an dass noch nicht genug Informationen fuer eine Einschaetzung vorliegen. Die Faktenchecks werden bei jedem Refresh automatisch aktualisiert und koennen sich im Laufe der Zeit aendern wenn neue Evidenz hinzukommt.
In der Faktencheck-Kachel werden zentrale Behauptungen aus den Artikeln mit einem Status markiert. Es gibt fuenf Status: "Bestaetigt" (gruenes Haekchen) heisst, mindestens zwei unabhaengige, serioese Quellen stuetzen die Aussage uebereinstimmend. "Gesichert" (gruenes Haekchen) bedeutet, drei oder mehr unabhaengige Quellen belegen den Sachverhalt, hohe Verlaesslichkeit. "Unbestaetigt" (Fragezeichen) zeigt an, dass die Aussage bisher nur aus einer Quelle stammt und eine unabhaengige Bestaetigung aussteht. "Umstritten" (Warndreieck) bedeutet, Quellen widersprechen sich, es gibt sowohl stuetzende als auch widersprechende Belege. "Widerlegt" (rotes Kreuz) heisst, zuverlaessige Quellen widersprechen der Aussage und sie ist wahrscheinlich falsch. Der Status kann sich bei spaeteren Aktualisierungen aendern, wenn neue Belege hinzukommen.
Benachrichtigungen und Abos:
Lagen koennen ueber das Glocken-Symbol abonniert werden. Es gibt verschiedene E-Mail-Benachrichtigungstypen: Zusammenfassung nach einem Refresh, Benachrichtigung bei neuen Artikeln und Benachrichtigung bei Statusaenderungen von Faktenchecks. Im Dashboard erscheinen neue Benachrichtigungen als Badge am Glocken-Symbol. Welche Benachrichtigungstypen gewuenscht sind, laesst sich pro Lage einzeln einstellen.
Lagen koennen ueber das Glocken-Symbol abonniert werden. Beim Anlegen oder Bearbeiten einer Lage koennen drei E-Mail-Benachrichtigungen einzeln aktiviert werden: "Neues Lagebild" (bzw. Recherchebericht) informiert nach einer Aktualisierung ueber die neue Zusammenfassung, "Neue Artikel" meldet gefundene Artikel und "Statusaenderung Faktencheck" meldet, wenn sich der Status einer geprueften Aussage aendert. Im Dashboard erscheinen neue Benachrichtigungen zusaetzlich als Badge am Glocken-Symbol.
Export:
Im Lage-Detail gibt es einen Export-Button. Der Markdown-Export erzeugt einen vollstaendigen Lagebericht als .md-Datei mit Zusammenfassung, Artikeln und Faktenchecks. Der JSON-Export liefert strukturierte Daten zur Weiterverarbeitung in anderen Systemen.
Im Lage-Detail gibt es einen Export-Button. Der Nutzer waehlt im Export-Dialog zunaechst aus, welche Bereiche enthalten sein sollen: "Zusammenfassung", "Recherchebericht / Lagebild", "Faktencheck" und "Quellen". Als Format stehen "PDF" und "Word (DOCX)" zur Verfuegung. Mit "Exportieren" wird die Datei erzeugt und heruntergeladen.
Sichtbarkeit:
Jede Lage kann "oeffentlich" oder "privat" sein. Oeffentliche Lagen sind fuer alle Nutzer der Organisation sichtbar. Private Lagen kann nur der Ersteller sehen und bearbeiten. Die Sichtbarkeit laesst sich ueber das Einstellungs-Menue der jeweiligen Lage aendern.
@@ -321,8 +346,8 @@ Jede Lage kann "oeffentlich" oder "privat" sein. Oeffentliche Lagen sind fuer al
Retention (Aufbewahrung):
Standardmaessig werden Lagen unbegrenzt aufbewahrt. Es kann aber eine Aufbewahrungsdauer in Tagen eingestellt werden. Nach Ablauf wird die Lage automatisch archiviert. Archivierte Lagen bleiben lesbar, werden aber nicht mehr automatisch aktualisiert.
Kartenansicht (Geoparsing):
Artikel werden automatisch auf geografische Erwahnungen analysiert. Erkannte Orte erscheinen auf einer interaktiven Karte mit farbigen Markern. Die Farben zeigen die Relevanz: Rot fuer Hauptgeschehen, Orange fuer Reaktionen, Blau fuer Beteiligte und Grau fuer erwaehnte Orte. Bei vielen Markern werden diese zu Clustern zusammengefasst. Ein Klick auf einen Marker zeigt die zugehoerigen Artikel. Die Karte hat einen Vollbildmodus und die Kategorien lassen sich ueber Checkboxen in der Legende ein- und ausblenden.
Kartenansicht:
In der Karten-Kachel erscheinen alle zur Lage erkannten Orte als farbige Marker. Die Farben zeigen die Relevanz: Rot fuer Hauptgeschehen, Orange fuer Reaktionen, Blau fuer Beteiligte und Grau fuer erwaehnte Orte. Bei vielen Markern werden diese zu Clustern zusammengefasst, ein Klick auf einen Marker oeffnet die zugehoerigen Artikel. Ueber das Vollbild-Symbol laesst sich die Karte grossformatig anzeigen, die Kategorien koennen ueber Checkboxen in der Legende ein- und ausgeblendet werden.
Quellenausschluss:
Bestimmte Domains koennen ueber die Quellen-Einstellungen blockiert werden. Blockierte Quellen tauchen dann in keiner Lage mehr auf. So lassen sich unerwuenschte oder unzuverlaessige Quellen dauerhaft ausschliessen.
@@ -390,7 +415,8 @@ def _build_prompt(user_message: str, history: list[dict]) -> str:
@router.post("", response_model=ChatResponse)
async def chat(
req: ChatRequest,
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Chat-Nachricht verarbeiten und Antwort generieren."""
user_id = current_user["id"]
@@ -415,15 +441,23 @@ async def chat(
# Claude CLI aufrufen
try:
result, duration_ms = await _call_claude_chat(prompt)
result, duration_ms, usage = await _call_claude_chat(prompt)
except TimeoutError:
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
except RuntimeError as e:
error_str = str(e)
if "rate_limit" in error_str:
except ClaudeCliError as e:
if e.error_type == "rate_limit":
raise HTTPException(status_code=429, detail="Der Assistent ist gerade ausgelastet. Bitte versuche es in einer Minute erneut.")
logger.error(f"Chat Claude-Fehler: {e}")
if e.error_type == "auth_error":
raise HTTPException(status_code=503, detail="KI-Zugang aktuell nicht verfuegbar. Bitte Administrator kontaktieren.")
logger.error(f"Chat Claude-Fehler [{e.error_type}]: {e}")
raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.")
except RuntimeError as e:
logger.error(f"Chat Claude-Fehler (unspezifisch): {e}")
raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.")
# Credits buchen
await charge_usage_to_tenant(db, current_user.get("tenant_id"), usage, source="chat")
await db.commit()
# Output sanitieren
reply = _sanitize_output(result)

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -1,406 +0,0 @@
"""Router für Netzwerkanalyse CRUD-Operationen."""
import hashlib
import json
import csv
import io
from datetime import datetime
from typing import Optional
import aiosqlite
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from auth import get_current_user
from database import db_dependency, get_db
from middleware.license_check import require_writable_license
from models_network import (
NetworkAnalysisCreate,
NetworkAnalysisUpdate,
)
from config import TIMEZONE
router = APIRouter(prefix="/api/network-analyses", tags=["network-analyses"])
async def _check_analysis_access(
db: aiosqlite.Connection, analysis_id: int, tenant_id: int
) -> dict:
"""Analyse laden und Tenant-Zugriff prüfen."""
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE id = ? AND tenant_id = ?",
(analysis_id, tenant_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Netzwerkanalyse nicht gefunden")
return dict(row)
async def _enrich_analysis(db: aiosqlite.Connection, analysis: dict) -> dict:
"""Analyse mit Incident-IDs, Titeln und Ersteller-Name anreichern."""
analysis_id = analysis["id"]
cursor = await db.execute(
"""SELECT nai.incident_id, i.title
FROM network_analysis_incidents nai
LEFT JOIN incidents i ON i.id = nai.incident_id
WHERE nai.network_analysis_id = ?""",
(analysis_id,),
)
rows = await cursor.fetchall()
analysis["incident_ids"] = [r["incident_id"] for r in rows]
analysis["incident_titles"] = [r["title"] or "" for r in rows]
cursor = await db.execute(
"SELECT email FROM users WHERE id = ?", (analysis.get("created_by"),)
)
user_row = await cursor.fetchone()
analysis["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
analysis["created_at"] = analysis.get("created_at", "")
analysis["has_update"] = False
return analysis
async def _calculate_data_hash(db: aiosqlite.Connection, analysis_id: int) -> str:
"""SHA-256 über verknüpfte Artikel- und Factcheck-Daten."""
cursor = await db.execute(
"""SELECT DISTINCT a.id, a.collected_at
FROM network_analysis_incidents nai
JOIN articles a ON a.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY a.id""",
(analysis_id,),
)
article_rows = await cursor.fetchall()
cursor = await db.execute(
"""SELECT DISTINCT fc.id, fc.checked_at
FROM network_analysis_incidents nai
JOIN fact_checks fc ON fc.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY fc.id""",
(analysis_id,),
)
fc_rows = await cursor.fetchall()
parts = []
for r in article_rows:
parts.append(f"a:{r['id']}:{r['collected_at']}")
for r in fc_rows:
parts.append(f"fc:{r['id']}:{r['checked_at']}")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
# --- Endpoints ---
@router.get("")
async def list_network_analyses(
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Alle Netzwerkanalysen des Tenants auflisten."""
tenant_id = current_user["tenant_id"]
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE tenant_id = ? ORDER BY created_at DESC",
(tenant_id,),
)
rows = await cursor.fetchall()
results = []
for row in rows:
results.append(await _enrich_analysis(db, dict(row)))
return results
@router.post("", status_code=201, dependencies=[Depends(require_writable_license)])
async def create_network_analysis(
body: NetworkAnalysisCreate,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Neue Netzwerkanalyse erstellen und Generierung starten."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
if not body.incident_ids:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
# Prüfen ob alle Incidents dem Tenant gehören
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(
status_code=400,
detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}"
)
# Analyse anlegen
cursor = await db.execute(
"""INSERT INTO network_analyses (name, status, tenant_id, created_by)
VALUES (?, 'generating', ?, ?)""",
(body.name, tenant_id, user_id),
)
analysis_id = cursor.lastrowid
for incident_id in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, incident_id),
)
await db.commit()
# Hintergrund-Generierung starten
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.get("/{analysis_id}")
async def get_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Einzelne Netzwerkanalyse abrufen."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
return await _enrich_analysis(db, analysis)
@router.get("/{analysis_id}/graph")
async def get_network_graph(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Volle Graphdaten (Entities + Relations)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities_raw = await cursor.fetchall()
entities = []
for e in entities_raw:
ed = dict(e)
# JSON-Felder parsen
try:
ed["aliases"] = json.loads(ed.get("aliases", "[]"))
except (json.JSONDecodeError, TypeError):
ed["aliases"] = []
try:
ed["metadata"] = json.loads(ed.get("metadata", "{}"))
except (json.JSONDecodeError, TypeError):
ed["metadata"] = {}
ed["corrected_by_opus"] = bool(ed.get("corrected_by_opus", 0))
entities.append(ed)
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations_raw = await cursor.fetchall()
relations = []
for r in relations_raw:
rd = dict(r)
try:
rd["evidence"] = json.loads(rd.get("evidence", "[]"))
except (json.JSONDecodeError, TypeError):
rd["evidence"] = []
relations.append(rd)
return {"analysis": analysis, "entities": entities, "relations": relations}
@router.post("/{analysis_id}/regenerate", dependencies=[Depends(require_writable_license)])
async def regenerate_network(
analysis_id: int,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse neu generieren."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
await _check_analysis_access(db, analysis_id, tenant_id)
# Bestehende Daten löschen
await db.execute(
"DELETE FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""DELETE FROM network_entity_mentions WHERE entity_id IN
(SELECT id FROM network_entities WHERE network_analysis_id = ?)""",
(analysis_id,),
)
await db.execute(
"DELETE FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""UPDATE network_analyses
SET status = 'generating', entity_count = 0, relation_count = 0, data_hash = NULL
WHERE id = ?""",
(analysis_id,),
)
await db.commit()
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
return {"status": "generating", "message": "Neugenerierung gestartet"}
@router.get("/{analysis_id}/check-update")
async def check_network_update(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Prüft ob neue Daten verfügbar (Hash-Vergleich)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
current_hash = await _calculate_data_hash(db, analysis_id)
stored_hash = analysis.get("data_hash")
has_update = stored_hash is not None and current_hash != stored_hash
return {"has_update": has_update}
@router.put("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def update_network_analysis(
analysis_id: int,
body: NetworkAnalysisUpdate,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Name oder Lagen aktualisieren."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann bearbeiten")
if body.name is not None:
await db.execute(
"UPDATE network_analyses SET name = ? WHERE id = ?",
(body.name, analysis_id),
)
if body.incident_ids is not None:
if len(body.incident_ids) < 1:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(status_code=400, detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}")
await db.execute(
"DELETE FROM network_analysis_incidents WHERE network_analysis_id = ?",
(analysis_id,),
)
for iid in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, iid),
)
await db.commit()
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.delete("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def delete_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse löschen (CASCADE räumt auf)."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann löschen")
await db.execute("DELETE FROM network_analyses WHERE id = ?", (analysis_id,))
await db.commit()
return {"message": "Netzwerkanalyse gelöscht"}
@router.get("/{analysis_id}/export")
async def export_network(
analysis_id: int,
format: str = Query("json", pattern="^(json|csv)$"),
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Export als JSON oder CSV."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities = [dict(r) for r in await cursor.fetchall()]
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations = [dict(r) for r in await cursor.fetchall()]
entity_map = {e["id"]: e for e in entities}
safe_name = (analysis.get("name", "export") or "export").replace(" ", "_")
if format == "json":
content = json.dumps(
{"analysis": analysis, "entities": entities, "relations": relations},
ensure_ascii=False, indent=2, default=str,
)
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.json"'},
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["source", "target", "category", "label", "weight", "description"])
for rel in relations:
src = entity_map.get(rel.get("source_entity_id"), {})
tgt = entity_map.get(rel.get("target_entity_id"), {})
writer.writerow([
src.get("name", ""), tgt.get("name", ""),
rel.get("category", ""), rel.get("label", ""),
rel.get("weight", 1), rel.get("description", ""),
])
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.csv"'},
)

Datei anzeigen

@@ -1,7 +1,7 @@
"""Öffentliche API für die Lagebild-Seite auf aegissight.de.
Authentifizierung via X-API-Key Header (getrennt von der JWT-Auth).
Exponiert den Irankonflikt (alle zugehörigen Incidents) als read-only.
Exponiert öffentliche Lagen als read-only.
"""
import json
import logging
@@ -50,14 +50,23 @@ def _in_clause(ids):
return ",".join(str(int(i)) for i in ids)
@router.get("/lagebild", dependencies=[Depends(verify_api_key)])
async def get_lagebild(db=Depends(db_dependency)):
"""Liefert das aktuelle Lagebild (Irankonflikt) mit allen Daten."""
ids = _in_clause(IRAN_INCIDENT_IDS)
# ──────────────────────────────────────────────────────────────────
# Shared-Logik für Lagebild-Responses
# ──────────────────────────────────────────────────────────────────
async def _build_lagebild_response(db, incident_ids: list, primary_id: int) -> dict:
"""Baut die Lagebild-Response für beliebige Incidents.
Args:
db: Datenbankverbindung
incident_ids: Liste der Incident-IDs (für Iran: [6,18,19,20], sonst: [55])
primary_id: ID des Haupt-Incidents für Metadaten
"""
ids = _in_clause(incident_ids)
# Haupt-Incident laden (für Summary, Sources)
cursor = await db.execute(
"SELECT * FROM incidents WHERE id = ?", (PRIMARY_INCIDENT_ID,)
"SELECT * FROM incidents WHERE id = ?", (primary_id,)
)
incident = await cursor.fetchone()
if not incident:
@@ -72,7 +81,7 @@ async def get_lagebild(db=Depends(db_dependency)):
except (json.JSONDecodeError, TypeError):
pass
# Alle Artikel aus allen Iran-Incidents laden
# Alle Artikel laden
cursor = await db.execute(
f"""SELECT id, headline, headline_de, source, source_url, language,
published_at, collected_at, verification_status, incident_id
@@ -81,7 +90,7 @@ async def get_lagebild(db=Depends(db_dependency)):
)
articles = [dict(r) for r in await cursor.fetchall()]
# Alle Faktenchecks aus allen Iran-Incidents laden
# Alle Faktenchecks laden
cursor = await db.execute(
f"""SELECT id, claim, status, sources_count, evidence, status_history, checked_at, incident_id
FROM fact_checks WHERE incident_id IN ({ids})
@@ -102,7 +111,7 @@ async def get_lagebild(db=Depends(db_dependency)):
)
source_count = (await cursor.fetchone())["cnt"]
# Snapshots aus allen Iran-Incidents
# Snapshots
cursor = await db.execute(
f"""SELECT id, incident_id, article_count, fact_check_count, created_at
FROM incident_snapshots WHERE incident_id IN ({ids})
@@ -133,6 +142,30 @@ async def get_lagebild(db=Depends(db_dependency)):
)
locations = [dict(r) for r in await cursor.fetchall()]
# Top-3-Artikel pro Location (neueste zuerst)
cursor = await db.execute(
f"""SELECT al.location_name_normalized as loc_name,
a.headline_de, a.headline, a.source, a.source_url
FROM article_locations al
JOIN articles a ON a.id = al.article_id
WHERE al.incident_id IN ({ids})
ORDER BY a.published_at DESC"""
)
loc_articles = {}
for r in await cursor.fetchall():
r = dict(r)
name = r["loc_name"]
if name not in loc_articles:
loc_articles[name] = []
if len(loc_articles[name]) < 3:
loc_articles[name].append({
"headline": r["headline_de"] or r["headline"] or "",
"source": r["source"] or "",
"url": r["source_url"] or "",
})
for loc in locations:
loc["top_articles"] = loc_articles.get(loc["name"], [])
return {
"generated_at": datetime.now(TIMEZONE).isoformat(),
"incident": {
@@ -146,6 +179,7 @@ async def get_lagebild(db=Depends(db_dependency)):
"article_count": len(articles),
"source_count": source_count,
"factcheck_count": len(fact_checks),
"latest_developments": incident.get("latest_developments") or "",
},
"current_lagebild": {
"summary_markdown": incident.get("summary", ""),
@@ -160,10 +194,9 @@ async def get_lagebild(db=Depends(db_dependency)):
}
@router.get("/lagebild/snapshot/{snapshot_id}", dependencies=[Depends(verify_api_key)])
async def get_snapshot(snapshot_id: int, db=Depends(db_dependency)):
"""Liefert einen historischen Snapshot."""
ids = _in_clause(IRAN_INCIDENT_IDS)
async def _get_snapshot_response(db, snapshot_id: int, incident_ids: list) -> dict:
"""Liefert einen historischen Snapshot für die angegebenen Incidents."""
ids = _in_clause(incident_ids)
cursor = await db.execute(
f"""SELECT id, summary, sources_json, article_count, fact_check_count, created_at
FROM incident_snapshots
@@ -181,3 +214,233 @@ async def get_snapshot(snapshot_id: int, db=Depends(db_dependency)):
snap["sources_json"] = []
return snap
# ──────────────────────────────────────────────────────────────────
# Endpunkte
# ──────────────────────────────────────────────────────────────────
@router.get("/lagebild", dependencies=[Depends(verify_api_key)])
async def get_lagebild(db=Depends(db_dependency)):
"""Liefert das aktuelle Lagebild (Irankonflikt) mit allen Daten.
Abwärtskompatibel — aggregiert die Iran-Incidents 6, 18, 19, 20.
"""
return await _build_lagebild_response(db, IRAN_INCIDENT_IDS, PRIMARY_INCIDENT_ID)
@router.post("/globe-ingest", dependencies=[Depends(verify_api_key)])
async def globe_ingest(
request: Request,
db=Depends(db_dependency),
):
"""Nimmt externe Ereignisse (EONET, USGS) als Artikel in eine Lage auf."""
import json as _json
body = await request.json()
incident_id = body.get("incident_id")
events = body.get("events", [])
if not incident_id or not events:
raise HTTPException(status_code=400, detail="incident_id und events erforderlich")
# Pruefen ob Lage existiert
cursor = await db.execute("SELECT id FROM incidents WHERE id = ?", (incident_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
inserted = 0
for evt in events[:50]: # Max 50 pro Call
headline = evt.get("title", "")[:500]
if not headline:
continue
# Duplikat-Check per Headline + Lage
cursor = await db.execute(
"SELECT id FROM articles WHERE incident_id = ? AND headline = ? LIMIT 1",
(incident_id, headline),
)
if await cursor.fetchone():
continue
source = evt.get("source", "Globe GEOINT")
source_url = evt.get("url", "")
content = evt.get("description", "")
lat = evt.get("lat")
lon = evt.get("lon")
category = evt.get("category", "primary")
await db.execute(
"""INSERT INTO articles (incident_id, headline, headline_de, source, source_url,
content_original, language, collected_at, verification_status)
VALUES (?, ?, ?, ?, ?, ?, 'de', datetime('now'), 'pending')""",
(incident_id, headline, headline, source, source_url, content),
)
article_id = (await db.execute("SELECT last_insert_rowid()")).fetchone()
article_id = (await article_id)[0] if article_id else None
# Location direkt einfuegen wenn Koordinaten vorhanden
if article_id and lat and lon:
await db.execute(
"""INSERT INTO article_locations
(article_id, incident_id, location_name, location_name_normalized,
latitude, longitude, confidence, category)
VALUES (?, ?, ?, ?, ?, ?, 0.9, ?)""",
(article_id, incident_id, evt.get("location", headline[:50]),
evt.get("location", headline[:50]).lower(), lat, lon, category),
)
inserted += 1
await db.commit()
return {"ok": True, "inserted": inserted, "total_sent": len(events)}
@router.get("/globe-incidents", dependencies=[Depends(verify_api_key)])
async def get_globe_incidents(db=Depends(db_dependency)):
"""Liste aller oeffentlichen aktiven Lagen fuer Globe-Auswahl."""
cursor = await db.execute(
"""SELECT id, title, type, status, updated_at
FROM incidents
WHERE status = 'active' AND type = 'adhoc' AND visibility = 'public'
ORDER BY updated_at DESC LIMIT 30"""
)
return [dict(r) for r in await cursor.fetchall()]
@router.get("/globe-feed", dependencies=[Depends(verify_api_key)])
async def get_globe_feed(
incident_id: int = None,
db=Depends(db_dependency),
):
"""Globe-Feed: Geoparsete Standorte mit Artikeln pro Ort."""
import json as _json
if incident_id:
cursor = await db.execute(
"SELECT id, title, description, summary, updated_at, type, status, category_labels "
"FROM incidents WHERE id = ?", (incident_id,)
)
else:
cursor = await db.execute(
"SELECT id, title, description, summary, updated_at, type, status, category_labels "
"FROM incidents WHERE visibility = 'public' AND status = 'active' AND type = 'adhoc' "
"ORDER BY updated_at DESC LIMIT 10"
)
incidents = [dict(r) for r in await cursor.fetchall()]
if not incidents:
return {"type": "FeatureCollection", "features": [], "incidents": []}
inc_ids = [i["id"] for i in incidents]
ids_sql = ",".join(str(i) for i in inc_ids)
# Alle Locations mit Artikel-IDs holen
cursor = await db.execute(
f"""SELECT al.location_name_normalized as name,
ROUND(al.latitude, 4) as lat, ROUND(al.longitude, 4) as lon,
al.country_code, al.category, al.incident_id, al.article_id
FROM article_locations al
WHERE al.incident_id IN ({ids_sql})"""
)
loc_rows = [dict(r) for r in await cursor.fetchall()]
# Alle referenzierten Artikel laden
art_ids = list(set(r["article_id"] for r in loc_rows if r.get("article_id")))
articles_by_id = {}
if art_ids:
for chunk_start in range(0, len(art_ids), 500):
chunk = art_ids[chunk_start:chunk_start+500]
aids = ",".join(str(a) for a in chunk)
cursor = await db.execute(
f"SELECT id, headline_de, headline, source, source_url, content_de, "
f"published_at, collected_at FROM articles WHERE id IN ({aids})"
)
for a in await cursor.fetchall():
a = dict(a)
articles_by_id[a["id"]] = a
# Nach Ort gruppieren
loc_map = {}
for r in loc_rows:
key = (r["name"] or "unknown", r["incident_id"])
if key not in loc_map:
loc_map[key] = {
"lat": r["lat"], "lon": r["lon"], "country": r["country_code"],
"category": r["category"], "incident_id": r["incident_id"],
"seen_ids": set(), "articles": [],
}
g = loc_map[key]
aid = r.get("article_id")
if aid and aid in articles_by_id and aid not in g["seen_ids"]:
g["seen_ids"].add(aid)
g["articles"].append(articles_by_id[aid])
# GeoJSON bauen
features = []
for (name, inc_id), g in list(loc_map.items())[:500]:
inc = next((i for i in incidents if i["id"] == inc_id), None)
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [g["lon"], g["lat"]]},
"properties": {
"name": name,
"country": g["country"],
"category": g["category"],
"article_count": len(g["articles"]),
"incident_id": inc_id,
"incident_title": inc["title"] if inc else "",
"articles": [{
"headline": a.get("headline_de") or a.get("headline", ""),
"source": a.get("source", ""),
"url": a.get("source_url", ""),
"summary": (a.get("content_de") or "")[:300],
"date": a.get("published_at") or a.get("collected_at", ""),
} for a in g["articles"][:5]],
},
})
inc_summaries = []
for i in incidents:
inc_summaries.append({
"id": i["id"], "title": i["title"], "type": i["type"],
"status": i["status"], "summary": (i.get("summary") or "")[:1000],
"updated_at": i["updated_at"],
})
return {
"type": "FeatureCollection",
"features": features,
"incidents": inc_summaries,
"generated_at": datetime.now(TIMEZONE).isoformat(),
}
# WICHTIG: Snapshot-Routen VOR der generischen /{incident_id}-Route,
# damit /lagebild/snapshot/123 nicht als incident_id="snapshot" gematcht wird.
@router.get("/lagebild/snapshot/{snapshot_id}", dependencies=[Depends(verify_api_key)])
async def get_snapshot(snapshot_id: int, db=Depends(db_dependency)):
"""Liefert einen historischen Snapshot (Irankonflikt, abwärtskompatibel)."""
return await _get_snapshot_response(db, snapshot_id, IRAN_INCIDENT_IDS)
@router.get("/lagebild/{incident_id}/snapshot/{snapshot_id}", dependencies=[Depends(verify_api_key)])
async def get_snapshot_by_incident(incident_id: int, snapshot_id: int, db=Depends(db_dependency)):
"""Liefert einen historischen Snapshot für eine beliebige öffentliche Lage."""
cursor = await db.execute(
"SELECT id FROM incidents WHERE id = ? AND visibility = 'public'",
(incident_id,),
)
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Lage nicht gefunden oder nicht öffentlich")
return await _get_snapshot_response(db, snapshot_id, [incident_id])
@router.get("/lagebild/{incident_id}", dependencies=[Depends(verify_api_key)])
async def get_lagebild_by_id(incident_id: int, db=Depends(db_dependency)):
"""Liefert das Lagebild für eine beliebige öffentliche Lage."""
cursor = await db.execute(
"SELECT id FROM incidents WHERE id = ? AND visibility = 'public'",
(incident_id,),
)
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Lage nicht gefunden oder nicht öffentlich")
return await _build_lagebild_response(db, [incident_id], incident_id)

Datei anzeigen

@@ -415,12 +415,14 @@ async def create_source(
"""Neue Quelle hinzufuegen (org-spezifisch)."""
tenant_id = current_user.get("tenant_id")
# Domain normalisieren (Subdomain-Aliase auflösen)
# Domain normalisieren (Subdomain-Aliase auflösen, aus URL extrahieren)
domain = data.domain
if not domain and data.url:
domain = _extract_domain(data.url)
if domain:
domain = _DOMAIN_ALIASES.get(domain.lower(), domain.lower())
# Duplikat-Prüfung: gleiche URL bereits vorhanden?
# Duplikat-Prüfung 1: gleiche URL bereits vorhanden? (tenant-übergreifend)
if data.url:
cursor = await db.execute(
"SELECT id, name FROM sources WHERE url = ? AND status = 'active'",
@@ -433,6 +435,25 @@ async def create_source(
detail=f"Feed-URL bereits vorhanden: {existing['name']} (ID {existing['id']})",
)
# Duplikat-Prüfung 2: Domain bereits vorhanden? (tenant-übergreifend)
if domain:
cursor = await db.execute(
"SELECT id, name, source_type FROM sources WHERE LOWER(domain) = ? AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?) LIMIT 1",
(domain.lower(), tenant_id),
)
domain_existing = await cursor.fetchone()
if domain_existing:
if data.source_type == "web_source":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Web-Quelle für '{domain}' bereits vorhanden: {domain_existing['name']}",
)
if not data.url:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Domain '{domain}' bereits als Quelle vorhanden: {domain_existing['name']}. Für einen neuen RSS-Feed bitte die Feed-URL angeben.",
)
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",

0
src/routes/__init__.py Normale Datei
Datei anzeigen

54
src/routes/version_router.py Normale Datei
Datei anzeigen

@@ -0,0 +1,54 @@
"""Version + Release-Notes-Endpoints fuer das Frontend-Update-System."""
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
RELEASES_FILE = REPO_ROOT / 'RELEASES.json'
# Version-Hash beim Boot einmalig auslesen.
try:
COMMIT_HASH = subprocess.check_output(
['git', 'rev-parse', '--short=10', 'HEAD'],
cwd=str(REPO_ROOT), text=True, timeout=5
).strip()
except Exception:
COMMIT_HASH = 'unknown'
DEPLOYED_AT = datetime.now(timezone.utc).isoformat()
router = APIRouter(tags=['version'])
@router.get('/api/version')
def version():
return {'commit': COMMIT_HASH, 'deployed_at': DEPLOYED_AT}
@router.get('/api/release-notes')
def release_notes(since: str = '', limit: int = 5):
"""Liefert Release-Notes seit der gegebenen Version.
'since' = letzte vom User gesehene Version. Liefert alle Eintraege NEUER
als diese Version. Ohne 'since' werden die letzten 'limit' Eintraege
geliefert.
"""
if not RELEASES_FILE.exists():
return {'entries': [], 'current': COMMIT_HASH}
try:
with open(RELEASES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
return {'entries': [], 'error': f'parse-failed: {e}'}
if since:
result = []
for entry in data:
if entry.get('version') == since:
break
result.append(entry)
return {'entries': result[:limit], 'current': COMMIT_HASH}
return {'entries': data[:limit], 'current': COMMIT_HASH}

Datei anzeigen

@@ -1,5 +1,6 @@
"""Lizenz-Verwaltung und -Pruefung."""
import logging
import os
from datetime import datetime
from config import TIMEZONE
import aiosqlite
@@ -7,11 +8,21 @@ import aiosqlite
logger = logging.getLogger("osint.license")
def _staging_mode() -> bool:
"""Staging-Mode aktiv? Wenn ja, gilt: immer unlimited Budget, kein Hard-Stop.
Wird ueber ENV-Variable STAGING_MODE=1 (oder true) aktiviert.
Nur in Staging-.env gesetzt; Live-.env hat das Flag nicht.
"""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"""Prueft den Lizenzstatus einer Organisation.
Returns:
dict mit: valid, status, license_type, max_users, current_users, read_only, message
dict mit: valid, status, license_type, max_users, current_users, read_only,
read_only_reason, message, unlimited_budget, credits_total, credits_used
"""
# Organisation pruefen
cursor = await db.execute(
@@ -20,10 +31,14 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
)
org = await cursor.fetchone()
if not org:
return {"valid": False, "status": "not_found", "read_only": True, "message": "Organisation nicht gefunden"}
return {"valid": False, "status": "not_found", "read_only": True,
"read_only_reason": "not_found",
"message": "Organisation nicht gefunden"}
if not org["is_active"]:
return {"valid": False, "status": "org_disabled", "read_only": True, "message": "Organisation deaktiviert"}
return {"valid": False, "status": "org_disabled", "read_only": True,
"read_only_reason": "org_disabled",
"message": "Organisation deaktiviert"}
# Aktive Lizenz suchen
cursor = await db.execute(
@@ -35,7 +50,19 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
license_row = await cursor.fetchone()
if not license_row:
return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"}
return {"valid": False, "status": "no_license", "read_only": True,
"read_only_reason": "no_license",
"message": "Keine aktive Lizenz"}
# Felder zur weiteren Verwendung extrahieren
lic_dict = dict(license_row)
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode():
unlimited_budget = True
# Ablauf pruefen
now = datetime.now(TIMEZONE)
@@ -52,11 +79,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"status": "expired",
"license_type": license_row["license_type"],
"read_only": True,
"read_only_reason": "expired",
"message": "Lizenz abgelaufen",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
}
except (ValueError, TypeError):
pass
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited)
budget_exceeded = False
if not unlimited_budget and credits_total and credits_total > 0:
if credits_used >= credits_total:
budget_exceeded = True
# Nutzerzahl pruefen
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
@@ -64,6 +101,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
)
current_users = (await cursor.fetchone())["cnt"]
if budget_exceeded:
return {
"valid": True, # Lizenz ist gueltig, aber Budget aufgebraucht -> read-only
"status": "budget_exceeded",
"license_type": license_row["license_type"],
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": True,
"read_only_reason": "budget_exceeded",
"message": "Token-Budget aufgebraucht",
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
}
return {
"valid": True,
"status": license_row["status"],
@@ -71,7 +123,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": False,
"read_only_reason": None,
"message": "Lizenz aktiv",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
}
@@ -91,6 +147,92 @@ async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[
return True, ""
async def charge_usage_to_tenant(
db: aiosqlite.Connection,
tenant_id: int | None,
usage,
source: str,
) -> None:
"""Verbucht Token-Verbrauch auf einen Tenant.
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source)
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt).
Args:
db: offene aiosqlite.Connection
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
source: 'monitor' | 'enhance' | 'chat'
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht.
"""
total_cost = getattr(usage, "total_cost_usd", None)
if total_cost is None:
total_cost = getattr(usage, "cost_usd", 0.0)
if not tenant_id:
logger.info(
f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen "
f"(cost=${total_cost:.4f})"
)
return
if total_cost <= 0:
return
input_tokens = getattr(usage, "input_tokens", 0)
output_tokens = getattr(usage, "output_tokens", 0)
cache_creation = getattr(usage, "cache_creation_tokens", 0)
cache_read = getattr(usage, "cache_read_tokens", 0)
api_calls = getattr(usage, "call_count", 1)
refresh_increment = 1 if source == "monitor" else 0
year_month = datetime.now(TIMEZONE).strftime("%Y-%m")
await db.execute(
"""
INSERT INTO token_usage_monthly
(organization_id, year_month, source, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(organization_id, year_month, source) DO UPDATE SET
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
api_calls = api_calls + excluded.api_calls,
refresh_count = refresh_count + excluded.refresh_count,
updated_at = CURRENT_TIMESTAMP
""",
(
tenant_id, year_month, source,
input_tokens, output_tokens, cache_creation, cache_read,
round(total_cost, 7), api_calls, refresh_increment,
),
)
lic_cursor = await db.execute(
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(tenant_id,),
)
lic = await lic_cursor.fetchone()
credits_consumed = 0.0
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
credits_consumed = total_cost / lic["cost_per_credit"]
await db.execute(
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
(round(credits_consumed, 2), tenant_id),
)
logger.info(
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: "
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits"
)
async def expire_licenses(db: aiosqlite.Connection):
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
cursor = await db.execute(

Datei anzeigen

@@ -0,0 +1,230 @@
"""Analysepipeline-Tracking: persistiert Pipeline-Schritte pro Refresh und sendet
Live-Status an die Frontend-Visualisierung.
Die Pipeline hat 9 Schritte und ist eine bewusst vereinfachte Außensicht der
internen Refresh-Pipeline (siehe orchestrator.py). Sie verschweigt Internas
(Modellnamen, Tools, Phasen, Multi-Pass-Labels) und beschreibt jeden Schritt in
verständlicher Sprache.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from config import TIMEZONE
logger = logging.getLogger("osint.pipeline")
# Single Source of Truth für die Pipeline-Definition.
# Reihenfolge bestimmt die Anzeige im Frontend.
PIPELINE_STEPS = [
{
"key": "sources_review",
"label": "Quellen sichten",
"icon": "search",
"tooltip": "Wir prüfen alle deine Nachrichtenquellen, ob sie aktuell erreichbar sind und was sie zu deiner Lage melden.",
},
{
"key": "collect",
"label": "Nachrichten sammeln",
"icon": "rss",
"tooltip": "Aus den passenden Quellen werden alle relevanten Meldungen eingesammelt - aus deinen RSS-Feeds, dem Web und optional Telegram-Kanälen.",
},
{
"key": "dedup",
"label": "Doppeltes filtern",
"icon": "copy-x",
"tooltip": "Mehrfach gemeldete Nachrichten werden zusammengefasst, damit nichts doppelt im Lagebild auftaucht.",
},
{
"key": "relevance",
"label": "Relevanz bewerten",
"icon": "scale",
"tooltip": "Jede Meldung wird darauf geprüft, ob sie wirklich zu deiner Lage passt. Themenfremdes wird aussortiert.",
},
{
"key": "geoparsing",
"label": "Orte erkennen",
"icon": "map-pin",
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet.",
},
{
"key": "factcheck",
"label": "Fakten prüfen",
"icon": "shield",
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?",
},
{
"key": "summary",
"label": "Lagebild verfassen",
"icon": "file-text",
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text.",
},
{
"key": "qc",
"label": "Qualitätscheck",
"icon": "check-circle",
"tooltip": "Eine letzte Kontrollprüfung am Ergebnis: Doppelte Fakten zusammenführen, Karten-Verortung prüfen, bevor du benachrichtigt wirst.",
},
{
"key": "notify",
"label": "Benachrichtigen",
"icon": "bell",
"tooltip": "Wenn etwas Wichtiges dabei war, gehen Benachrichtigungen raus, im Glockensymbol oben rechts und optional per E-Mail.",
},
]
VALID_KEYS = {s["key"] for s in PIPELINE_STEPS}
def _now_db() -> str:
"""Aktuelle Zeit im DB-Format (lokal)."""
return datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
async def _broadcast(ws_manager, incident_id: int, payload: dict,
visibility: str, created_by: Optional[int], tenant_id: Optional[int]):
"""Sendet ein pipeline_step-Event an verbundene Clients der Lage."""
if not ws_manager:
return
try:
await ws_manager.broadcast_for_incident(
{"type": "pipeline_step", "incident_id": incident_id, "data": payload},
visibility, created_by, tenant_id,
)
except Exception as e:
logger.warning(f"Pipeline-WS-Broadcast fehlgeschlagen: {e}")
async def start_step(db, ws_manager, *, refresh_log_id: int, incident_id: int,
step_key: str, pass_number: int = 1, tenant_id: Optional[int] = None,
visibility: str = "public", created_by: Optional[int] = None) -> Optional[int]:
"""Markiert einen Pipeline-Schritt als aktiv.
Returns die DB-ID der Step-Zeile (für späteres Update via complete_step), oder None bei Fehler.
"""
if step_key not in VALID_KEYS:
logger.warning(f"Unbekannter Pipeline-Schritt: {step_key}")
return None
try:
cursor = await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, status, tenant_id)
VALUES (?, ?, ?, ?, ?, 'active', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), tenant_id),
)
await db.commit()
step_id = cursor.lastrowid
except Exception as e:
logger.warning(f"Pipeline start_step({step_key}) DB-Fehler: {e}")
step_id = None
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "active",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)
return step_id
async def complete_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id: int,
incident_id: int, step_key: str, pass_number: int = 1,
count_value: Optional[int] = None, count_secondary: Optional[int] = None,
tenant_id: Optional[int] = None, visibility: str = "public",
created_by: Optional[int] = None):
"""Markiert einen Pipeline-Schritt als abgeschlossen, mit Zahlen."""
if step_key not in VALID_KEYS:
return
try:
if step_id:
await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'done', completed_at = ?, count_value = ?, count_secondary = ?
WHERE id = ?""",
(_now_db(), count_value, count_secondary, step_id),
)
else:
# Fallback wenn start_step keine ID lieferte
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, count_value, count_secondary, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'done', ?, ?, ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(),
count_value, count_secondary, tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline complete_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "done",
"pass_number": pass_number,
"count_value": count_value,
"count_secondary": count_secondary,
}, visibility, created_by, tenant_id)
async def skip_step(db, ws_manager, *, refresh_log_id: int, incident_id: int,
step_key: str, pass_number: int = 1, tenant_id: Optional[int] = None,
visibility: str = "public", created_by: Optional[int] = None):
"""Markiert einen Schritt als übersprungen (z.B. Geoparsing ohne neue Artikel)."""
if step_key not in VALID_KEYS:
return
try:
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'skipped', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(), tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline skip_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "skipped",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)
async def error_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id: int,
incident_id: int, step_key: str, pass_number: int = 1,
tenant_id: Optional[int] = None, visibility: str = "public",
created_by: Optional[int] = None):
"""Markiert einen Schritt als fehlgeschlagen."""
if step_key not in VALID_KEYS:
return
try:
if step_id:
await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'error', completed_at = ?
WHERE id = ?""",
(_now_db(), step_id),
)
else:
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'error', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(), tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline error_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "error",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)

Datei anzeigen

@@ -3,11 +3,13 @@
Prueft nach jedem Refresh:
1. Semantische Faktencheck-Duplikate (Haiku-Clustering mit Fuzzy-Vorfilter)
2. Falsch kategorisierte Karten-Locations (Haiku bewertet Kontext der Lage)
3. Umlaut-Normalisierung in summary + latest_developments (deterministisch)
Regelbasierte Listen dienen als Fallback falls Haiku fehlschlaegt.
"""
import json
import logging
import os
import re
from difflib import SequenceMatcher
@@ -397,19 +399,235 @@ async def run_post_refresh_qc(db, incident_id: int) -> dict:
locations_fixed = await check_location_categories(
db, incident_id, incident_title, incident_desc
)
umlauts_fixed = await normalize_umlaut_fields(db, incident_id)
article_umlauts_fixed = await normalize_umlaut_articles(db, incident_id)
if facts_removed > 0 or locations_fixed > 0:
total_umlaut_changes = umlauts_fixed + article_umlauts_fixed
if facts_removed > 0 or locations_fixed > 0 or total_umlaut_changes > 0:
await db.commit()
logger.info(
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert",
incident_id, facts_removed, locations_fixed,
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert (davon %d in Articles)",
incident_id, facts_removed, locations_fixed, total_umlaut_changes, article_umlauts_fixed,
)
return {"facts_removed": facts_removed, "locations_fixed": locations_fixed}
return {
"facts_removed": facts_removed,
"locations_fixed": locations_fixed,
"umlauts_fixed": total_umlaut_changes,
}
except Exception as e:
logger.error(
"Post-Refresh QC Fehler fuer Incident %d: %s",
incident_id, e, exc_info=True,
)
return {"facts_removed": 0, "locations_fixed": 0, "error": str(e)}
return {"facts_removed": 0, "locations_fixed": 0, "umlauts_fixed": 0, "error": str(e)}
# ---------------------------------------------------------------------------
# 3. Umlaut-Normalisierung (deterministisch, Sicherheitsnetz gegen LLM-Drift)
# ---------------------------------------------------------------------------
# Das grosse Mapping wird aus umlaut_dict.json geladen. Das JSON wird einmalig
# aus hunspell-de-de erzeugt (siehe scripts/build_umlaut_dict.py) und enthaelt
# >150.000 deutsche Umlaut-Woerter inklusive Flexionsformen. Mehrdeutigkeiten
# (z. B. "dass"/"daß", "Masse"/"Maße") sind bereits ausgefiltert.
_DICT_PATH = os.path.join(os.path.dirname(__file__), "umlaut_dict.json")
try:
with open(_DICT_PATH, encoding="utf-8") as _dict_file:
_UMLAUT_REPLACEMENTS = json.load(_dict_file)
logger.info("Umlaut-Dict geladen: %d Eintraege aus %s", len(_UMLAUT_REPLACEMENTS), _DICT_PATH)
except FileNotFoundError:
logger.warning("umlaut_dict.json nicht gefunden – Umlaut-Normalisierung laeuft mit leerem Dict")
_UMLAUT_REPLACEMENTS = {}
# _MANUAL_SUPPLEMENT: Lueckenfueller fuer Woerter, die hunspell-de-de nicht abdeckt
# (primaer Komposita und seltene Konjunktiv-Formen). Wird ueber das Korpus-Dict gelegt.
_MANUAL_SUPPLEMENT = {
# Konjunktiv I von "saeen" (selten, aber kommt vor)
"saee": "säe", "saeen": "säen", "gesaet": "gesät",
# Komposita mit Amtstitel, die hunspell als Teile kennt aber nicht kombiniert
"aussenminister": "außenminister", "aussenministerin": "außenministerin",
"aussenministern": "außenministern",
"aussenpolitik": "außenpolitik",
"aussenpolitisch": "außenpolitisch", "aussenpolitische": "außenpolitische",
"aussenpolitischer": "außenpolitischer", "aussenpolitischen": "außenpolitischen",
"vizepraesident": "vizepräsident", "vizepraesidenten": "vizepräsidenten",
"vizepraesidentin": "vizepräsidentin",
"parlamentspraesident": "parlamentspräsident",
"parlamentspraesidenten": "parlamentspräsidenten",
"parlamentspraesidentin": "parlamentspräsidentin",
"generalsekretaer": "generalsekretär", "generalsekretaerin": "generalsekretärin",
"generalsekretaers": "generalsekretärs",
"staatssekretaer": "staatssekretär", "staatssekretaerin": "staatssekretärin",
# Strassen-Komposita
"wasserstrasse": "wasserstraße", "wasserstrassen": "wasserstraßen",
"hauptstrasse": "hauptstraße", "autostrasse": "autostraße",
"bundesstrasse": "bundesstraße", "landstrasse": "landstraße",
# Militaer-Komposita (haeufig in OSINT-Kontext)
"militaerkommando": "militärkommando", "militaerbasis": "militärbasis",
"militaerschlag": "militärschlag", "militaerschlaege": "militärschläge",
# Suedeutsch-Doppel-D-Spezialfall (haendisch korrigierbar)
"suedeutsch": "süddeutsch", "suedeutsche": "süddeutsche",
"suedeutschen": "süddeutschen",
# Fuehrungs- und Oeffnungs-Komposita (hunspell kennt die Stamm-Woerter, nicht die Komposita)
"wiedereroeffnung": "wiedereröffnung", "wiedereroeffnungen": "wiedereröffnungen",
"kriegsfuehrung": "kriegsführung", "kriegsfuehrer": "kriegsführer",
"fuehrungsebene": "führungsebene", "fuehrungsebenen": "führungsebenen",
"fuehrungskraft": "führungskraft", "fuehrungskraefte": "führungskräfte",
"fuehrungsposition": "führungsposition", "fuehrungspositionen": "führungspositionen",
"fuehrungsrolle": "führungsrolle",
"geschaeftsfuehrer": "geschäftsführer", "geschaeftsfuehrung": "geschäftsführung",
"staatsfuehrung": "staatsführung", "parteifuehrung": "parteiführung",
"militaerfuehrung": "militärführung",
}
# Capitalize-Varianten fuer das Supplement (hunspell-Korpus hat sie schon eingebaut)
_MANUAL_SUPPLEMENT_FULL = {}
for _k, _v in _MANUAL_SUPPLEMENT.items():
_MANUAL_SUPPLEMENT_FULL[_k] = _v
if _k[:1].islower():
_MANUAL_SUPPLEMENT_FULL[_k[:1].upper() + _k[1:]] = _v[:1].upper() + _v[1:]
# Supplement ueber das Korpus-Dict legen (Supplement hat Vorrang bei Kollision)
_UMLAUT_REPLACEMENTS = {**_UMLAUT_REPLACEMENTS, **_MANUAL_SUPPLEMENT_FULL}
# Whitelist: Tokens, die trotz Dict-Match NIE ersetzt werden (Eigennamen,
# englische Fremdwoerter, Fachbegriffe). Greift vor dem Dict-Lookup.
_UMLAUT_WHITELIST = frozenset({
# Englische Fremdwoerter
"Boeing", "Business", "Access", "Process", "Message", "Password",
"Miss", "Boss", "Goethe", "Yahoo",
# Eigennamen, die zufaellig "ss" enthalten und nicht umgeschrieben werden sollen
"Israel", "Israels",
})
# Tokenizer: matcht Woerter aus Buchstaben (inkl. deutschen Umlauten).
# Performanter als ein alternierendes Regex ueber 150k Keys — O(1) Dict-Lookup pro Wort.
_WORD_PATTERN = re.compile(r"[A-Za-zÄÖÜäöüß]+")
def normalize_german_umlauts(text: str) -> tuple[str, int]:
"""Ersetzt typische deutsche Umschreibungen durch echte Umlaute.
Deterministisch, wortgrenzen-basiert, case-preserving. Sicher gegen
englische Wortbestandteile (Boeing, Business, Access) weil nur
explizit gelistete deutsche Woerter ersetzt werden.
Rueckgabe: (normalisierter_text, anzahl_ersetzungen)
"""
if not text:
return text, 0
count = [0]
def _replace(match: re.Match) -> str:
word = match.group(0)
if word in _UMLAUT_WHITELIST:
return word
replacement = _UMLAUT_REPLACEMENTS.get(word)
if replacement is None:
return word
count[0] += 1
return replacement
new_text = _WORD_PATTERN.sub(_replace, text)
return new_text, count[0]
async def normalize_umlaut_fields(db, incident_id: int) -> int:
"""Liest summary + latest_developments eines Incidents, normalisiert Umlaute,
schreibt bei tatsaechlichen Aenderungen zurueck.
Rueckgabe: Anzahl der Ersetzungen insgesamt (summary + latest_developments).
"""
cursor = await db.execute(
"SELECT summary, latest_developments FROM incidents WHERE id = ?",
(incident_id,),
)
row = await cursor.fetchone()
if not row:
return 0
orig_summary = row["summary"] or ""
orig_dev = row["latest_developments"] or ""
new_summary, count_summary = normalize_german_umlauts(orig_summary)
new_dev, count_dev = normalize_german_umlauts(orig_dev)
total = count_summary + count_dev
if total == 0:
return 0
await db.execute(
"UPDATE incidents SET summary = ?, latest_developments = ? WHERE id = ?",
(
new_summary if count_summary > 0 else orig_summary,
new_dev if count_dev > 0 else orig_dev,
incident_id,
),
)
logger.info(
"Umlaut-Normalisierung Incident %d: %d in summary, %d in latest_developments",
incident_id, count_summary, count_dev,
)
return total
async def normalize_umlaut_articles(db, incident_id: int) -> int:
"""Normalisiert Umlaute in allen Artikel-Texten des Incidents.
Felder die behandelt werden:
- headline_de und content_de bei allen Artikeln (LLM-Uebersetzung kann
ASCII-Umlaute liefern trotz Prompt-Anweisung)
- headline und content_original bei language='de' (manche Quellen wie
dpa-AFX, Telegram-Kanaele liefern selbst schon ASCII-Umlaute)
Idempotent: Wenn der Text schon korrekt ist, macht das Dict-Lookup
keine Aenderung und wir schreiben nicht zurueck.
Rueckgabe: Gesamtzahl der Wort-Ersetzungen ueber alle Artikel.
"""
cursor = await db.execute(
"""SELECT id, language, headline, headline_de, content_original, content_de
FROM articles WHERE incident_id = ?""",
(incident_id,),
)
rows = await cursor.fetchall()
if not rows:
return 0
total = 0
for row in rows:
is_de = (row["language"] or "").lower() == "de"
updates = {}
# Felder die immer behandelt werden (LLM-Uebersetzungen)
if row["headline_de"]:
new, n = normalize_german_umlauts(row["headline_de"])
if n > 0:
updates["headline_de"] = new
total += n
if row["content_de"]:
new, n = normalize_german_umlauts(row["content_de"])
if n > 0:
updates["content_de"] = new
total += n
# Originalfelder nur bei deutschen Quellen
if is_de:
if row["headline"]:
new, n = normalize_german_umlauts(row["headline"])
if n > 0:
updates["headline"] = new
total += n
if row["content_original"]:
new, n = normalize_german_umlauts(row["content_original"])
if n > 0:
updates["content_original"] = new
total += n
if updates:
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [row["id"]]
await db.execute(f"UPDATE articles SET {set_clause} WHERE id = ?", values)
return total

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Datei anzeigen

@@ -84,6 +84,8 @@ DOMAIN_CATEGORY_MAP = {
"ksta.de": "regional",
"rp-online.de": "regional",
"merkur.de": "regional",
# Telegram
"t.me": "telegram",
}
# Bekannte Feed-Pfade zum Durchprobieren
@@ -635,27 +637,32 @@ def _fallback_all_feeds(domain: str, feeds: list[dict]) -> list[dict]:
]
async def get_feeds_with_metadata(tenant_id: int = None) -> list[dict]:
"""Alle aktiven RSS-Feeds mit Metadaten fuer Claude-Selektion (global + org-spezifisch)."""
async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss_feed") -> list[dict]:
"""Aktive Feeds eines bestimmten Typs mit Metadaten fuer Claude-Selektion (global + org-spezifisch).
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
"""
from database import get_db
db = await get_db()
try:
if tenant_id:
cursor = await db.execute(
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = 'rss_feed' AND status = 'active' "
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
(tenant_id,),
(source_type, tenant_id),
)
else:
cursor = await db.execute(
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = 'rss_feed' AND status = 'active'"
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active'",
(source_type,),
)
return [dict(row) for row in await cursor.fetchall()]
except Exception as e:
logger.error(f"Fehler beim Laden der Feed-Metadaten: {e}")
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
return []
finally:
await db.close()

Datei anzeigen

@@ -1,710 +0,0 @@
/* === Netzwerkanalyse Styles === */
/* --- Sidebar: Netzwerkanalysen-Sektion --- */
.sidebar-network-item {
display: flex;
align-items: center;
gap: var(--sp-md);
padding: 6px 12px;
cursor: pointer;
border-radius: var(--radius);
transition: background 0.15s;
font-size: 13px;
color: var(--sidebar-text);
}
.sidebar-network-item:hover {
background: var(--sidebar-hover-bg);
}
.sidebar-network-item.active {
background: var(--tint-accent);
color: var(--sidebar-active);
}
.sidebar-network-item .network-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.7;
}
.sidebar-network-item .network-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-network-item .network-item-count {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.sidebar-network-item .network-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sidebar-network-item .network-status-dot.generating {
background: var(--warning);
animation: pulse-dot 1.5s infinite;
}
.sidebar-network-item .network-status-dot.ready {
background: var(--success);
}
.sidebar-network-item .network-status-dot.error {
background: var(--error);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* --- Typ-Badge für Netzwerk --- */
.incident-type-badge.type-network {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border: 1px solid rgba(99, 102, 241, 0.3);
}
/* --- Network View Layout --- */
#network-view {
display: none;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* --- Header Strip --- */
.network-header-strip {
padding: var(--sp-xl) var(--sp-3xl);
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
}
.network-header-row1 {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-xl);
}
.network-header-left {
display: flex;
align-items: center;
gap: var(--sp-lg);
min-width: 0;
}
.network-header-title {
font-family: var(--font-title);
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.network-header-actions {
display: flex;
align-items: center;
gap: var(--sp-md);
flex-shrink: 0;
}
.network-header-meta {
display: flex;
align-items: center;
gap: var(--sp-xl);
margin-top: var(--sp-md);
font-size: 12px;
color: var(--text-secondary);
}
.network-header-meta span {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
/* --- Update-Badge --- */
.network-update-badge {
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 9999px;
background: rgba(245, 158, 11, 0.15);
color: #F59E0B;
border: 1px solid rgba(245, 158, 11, 0.3);
cursor: pointer;
transition: background 0.15s;
}
.network-update-badge:hover {
background: rgba(245, 158, 11, 0.25);
}
/* --- Progress Bar (3 Schritte) --- */
.network-progress {
padding: var(--sp-lg) var(--sp-3xl);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.network-progress-steps {
display: flex;
align-items: center;
gap: var(--sp-md);
margin-bottom: var(--sp-md);
}
.network-progress-step {
display: flex;
align-items: center;
gap: var(--sp-sm);
font-size: 12px;
color: var(--text-disabled);
transition: color 0.3s;
}
.network-progress-step.active {
color: var(--accent);
font-weight: 600;
}
.network-progress-step.done {
color: var(--success);
}
.network-progress-step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-disabled);
transition: background 0.3s;
}
.network-progress-step.active .network-progress-step-dot {
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.network-progress-step.done .network-progress-step-dot {
background: var(--success);
}
.network-progress-connector {
flex: 1;
height: 2px;
background: var(--border);
position: relative;
}
.network-progress-connector.done {
background: var(--success);
}
.network-progress-track {
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.network-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.5s ease;
width: 0%;
}
.network-progress-label {
font-size: 12px;
color: var(--text-secondary);
margin-top: var(--sp-sm);
}
/* --- Main Content Area --- */
.network-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* --- Graph Area --- */
.network-graph-area {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-primary);
}
.network-graph-area svg {
width: 100%;
height: 100%;
display: block;
}
/* --- Rechte Sidebar --- */
.network-sidebar {
width: 300px;
border-left: 1px solid var(--border);
background: var(--bg-card);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.network-sidebar-section {
padding: var(--sp-lg) var(--sp-xl);
border-bottom: 1px solid var(--border);
}
.network-sidebar-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: var(--sp-md);
}
/* Suche */
.network-search-input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: var(--sp-md) var(--sp-lg);
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-body);
}
.network-search-input:focus {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-color: var(--accent);
}
.network-search-input::placeholder {
color: var(--text-disabled);
}
/* Typ-Filter */
.network-type-filters {
display: flex;
flex-wrap: wrap;
gap: var(--sp-sm);
}
.network-type-filter {
display: flex;
align-items: center;
gap: var(--sp-xs);
padding: 2px 8px;
font-size: 11px;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
transition: all 0.15s;
}
.network-type-filter.active {
border-color: currentColor;
background: currentColor;
}
.network-type-filter.active span {
color: #fff;
}
.network-type-filter[data-type="person"] { color: #60A5FA; }
.network-type-filter[data-type="organisation"] { color: #C084FC; }
.network-type-filter[data-type="location"] { color: #34D399; }
.network-type-filter[data-type="event"] { color: #FBBF24; }
.network-type-filter[data-type="military"] { color: #F87171; }
.network-type-filter .type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
/* Gewicht-Slider */
.network-weight-slider {
width: 100%;
accent-color: var(--accent);
}
.network-weight-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-disabled);
margin-top: 2px;
}
/* Detail-Panel */
.network-detail-panel {
flex: 1;
overflow-y: auto;
padding: var(--sp-xl);
}
.network-detail-empty {
padding: var(--sp-3xl) var(--sp-xl);
text-align: center;
font-size: 13px;
color: var(--text-disabled);
}
.network-detail-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--sp-sm);
}
.network-detail-type {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 8px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: var(--sp-lg);
}
.network-detail-type.type-person { background: rgba(96, 165, 250, 0.15); color: #60A5FA; }
.network-detail-type.type-organisation { background: rgba(192, 132, 252, 0.15); color: #C084FC; }
.network-detail-type.type-location { background: rgba(52, 211, 153, 0.15); color: #34D399; }
.network-detail-type.type-event { background: rgba(251, 191, 36, 0.15); color: #FBBF24; }
.network-detail-type.type-military { background: rgba(248, 113, 113, 0.15); color: #F87171; }
.network-detail-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: var(--sp-lg);
}
.network-detail-section {
margin-top: var(--sp-xl);
}
.network-detail-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: var(--sp-md);
}
.network-detail-aliases {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
}
.network-detail-alias {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-secondary);
}
.network-detail-stat {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: var(--sp-xs) 0;
color: var(--text-secondary);
}
.network-detail-stat strong {
color: var(--text-primary);
}
.network-opus-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius);
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
margin-left: var(--sp-sm);
}
/* Relation-Items im Detail-Panel */
.network-relation-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-md);
border-radius: var(--radius);
background: var(--bg-secondary);
margin-bottom: var(--sp-sm);
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.network-relation-item:hover {
background: var(--bg-hover);
}
.network-relation-header {
display: flex;
align-items: center;
gap: var(--sp-sm);
}
.network-relation-category {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0 5px;
border-radius: 3px;
}
.network-relation-category.cat-alliance { background: rgba(52, 211, 153, 0.15); color: #34D399; }
.network-relation-category.cat-conflict { background: rgba(239, 68, 68, 0.15); color: #EF4444; }
.network-relation-category.cat-diplomacy { background: rgba(251, 191, 36, 0.15); color: #FBBF24; }
.network-relation-category.cat-economic { background: rgba(96, 165, 250, 0.15); color: #60A5FA; }
.network-relation-category.cat-legal { background: rgba(192, 132, 252, 0.15); color: #C084FC; }
.network-relation-category.cat-neutral { background: rgba(107, 114, 128, 0.15); color: #6B7280; }
.network-relation-target {
color: var(--text-primary);
font-weight: 500;
}
.network-relation-label {
color: var(--text-secondary);
font-size: 11px;
}
.network-relation-weight {
font-size: 10px;
color: var(--text-disabled);
}
/* --- Graph Tooltip --- */
.network-tooltip {
position: absolute;
pointer-events: none;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--sp-md) var(--sp-lg);
font-size: 12px;
color: var(--text-primary);
box-shadow: var(--shadow-md);
z-index: 100;
max-width: 300px;
display: none;
}
.network-tooltip-title {
font-weight: 600;
margin-bottom: 2px;
}
.network-tooltip-desc {
color: var(--text-secondary);
font-size: 11px;
}
/* --- Graph SVG Styles --- */
.network-graph-area .node-label {
font-size: 10px;
fill: var(--text-secondary);
text-anchor: middle;
pointer-events: none;
user-select: none;
}
.network-graph-area .node-circle {
cursor: pointer;
stroke: var(--bg-primary);
stroke-width: 2;
transition: stroke-width 0.15s;
}
.network-graph-area .node-circle:hover {
stroke-width: 3;
stroke: var(--text-primary);
}
.network-graph-area .node-circle.selected {
stroke-width: 3;
stroke: var(--accent);
}
.network-graph-area .node-circle.dimmed {
opacity: 0.15;
}
.network-graph-area .node-circle.highlighted {
filter: drop-shadow(0 0 8px currentColor);
}
.network-graph-area .node-label.dimmed {
opacity: 0.1;
}
.network-graph-area .edge-line {
fill: none;
pointer-events: stroke;
cursor: pointer;
}
.network-graph-area .edge-line.dimmed {
opacity: 0.05 !important;
}
.network-graph-area .edge-line:hover {
stroke-width: 3 !important;
}
/* --- Modal: Neue Netzwerkanalyse --- */
.network-incident-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--input-border);
border-radius: var(--radius);
background: var(--input-bg);
}
.network-incident-search {
width: 100%;
padding: var(--sp-md) var(--sp-lg);
border: none;
border-bottom: 1px solid var(--input-border);
background: var(--input-bg);
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-body);
}
.network-incident-search:focus {
outline: none;
border-bottom-color: var(--accent);
}
.network-incident-search::placeholder {
color: var(--text-disabled);
}
.network-incident-option {
display: flex;
align-items: center;
gap: var(--sp-md);
padding: var(--sp-md) var(--sp-lg);
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
color: var(--text-primary);
}
.network-incident-option:hover {
background: var(--bg-hover);
}
.network-incident-option input[type="checkbox"] {
accent-color: var(--accent);
}
.network-incident-option .incident-option-type {
font-size: 10px;
color: var(--text-disabled);
margin-left: auto;
}
/* --- Leerer Graph-Zustand --- */
.network-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--sp-lg);
color: var(--text-disabled);
}
.network-empty-state-icon {
font-size: 48px;
opacity: 0.3;
}
.network-empty-state-text {
font-size: 14px;
}
/* Fix: Lagen-Checkboxen im Netzwerk-Modal */
.network-incident-option {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: var(--sp-md);
padding: var(--sp-md) var(--sp-lg);
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
text-transform: none;
letter-spacing: normal;
font-weight: 400;
margin-bottom: 0;
}
.network-incident-option input[type="checkbox"] {
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
cursor: pointer;
}
.network-incident-option span {
text-transform: none;
letter-spacing: normal;
font-weight: 400;
font-size: 13px;
color: var(--text-primary);
}
.network-incident-option .incident-option-type {
font-size: 10px;
font-weight: 500;
color: var(--text-disabled);
margin-left: auto;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -4,20 +4,25 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>(function(){var t=localStorage.getItem('osint_theme');if(t)document.documentElement.setAttribute('data-theme',t);try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<title>AegisSight Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack.min.css" rel="stylesheet">
<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/network.css?v=20260316a">
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
<link rel="stylesheet" href="/static/css/style.css?v=20260501h">
<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; }
.export-radio:hover { background:var(--bg-secondary); }
.export-radio input[type="radio"] { accent-color:var(--accent); width:16px; height:16px; cursor:pointer; flex-shrink:0; }
.export-radio input[type="radio"]:checked ~ span:first-of-type { color:var(--accent); font-weight:600; }
.export-radio span:first-of-type { font-size:13px; }
.export-radio-desc { font-size:11px; color:var(--text-tertiary); margin-left:auto; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
@@ -46,6 +51,12 @@
<span class="header-dropdown-label">Organisation</span>
<span class="header-dropdown-value" id="header-org-name">-</span>
</div>
<!-- Global Admin: Org-Switcher (herausnehmbar) -->
<div id="org-switcher-section" class="org-switcher-section" style="display: none;">
<div class="credits-divider"></div>
<label class="org-switcher-label" for="org-switcher-select">Wechseln zu:</label>
<select id="org-switcher-select" class="org-switcher-select"></select>
</div>
<div class="header-dropdown-row">
<span class="header-dropdown-label">Lizenz</span>
<span class="header-dropdown-value" id="header-license-info">-</span>
@@ -57,9 +68,15 @@
<div id="credits-bar" class="credits-bar"></div>
</div>
<div class="credits-info">
<span id="credits-remaining">0</span> von <span id="credits-total">0</span>
<span><span id="credits-remaining">0</span> von <span id="credits-total">0</span></span>
<span class="credits-percent" id="credits-percent"></span>
</div>
</div>
<div class="credits-divider"></div>
<button class="header-dropdown-action" type="button" onclick="AIDisclaimer && AIDisclaimer.show()">
<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"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span>Über KI-Inhalte</span>
</button>
</div>
</div>
<div class="header-license-warning" id="header-license-warning"></div>
@@ -70,8 +87,7 @@
<!-- Sidebar -->
<nav class="sidebar" aria-label="Seitenleiste">
<div class="sidebar-section">
<button class="btn btn-primary btn-full btn-small" id="new-incident-btn" style="margin-bottom:6px;">+ Neue Lage</button>
<!-- <button class="btn btn-primary btn-full btn-small" id="new-network-btn" onclick="App.openNetworkModal()">+ Neue Netzwerkanalyse</button> -->
<button class="btn btn-primary btn-full btn-small" id="new-incident-btn" style="margin-bottom:6px;">+ Neuer Fall</button>
</div>
<div class="sidebar-filter">
@@ -91,22 +107,13 @@
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-active-research" aria-hidden="true">&#9662;</span>
Deep-Research
Recherchen
<span class="sidebar-section-count" id="count-active-research"></span>
</h2>
<div id="active-research" aria-live="polite"></div>
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('network-analyses-list')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-network-analyses-list" aria-hidden="true">&#9662;</span>
Netzwerkanalysen
<span class="sidebar-section-count" id="count-network-analyses"></span>
</h2>
<div id="network-analyses-list" aria-live="polite"></div>
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0" aria-expanded="false">
<span class="sidebar-chevron" id="chevron-archived-incidents" aria-hidden="true">&#9662;</span>
@@ -116,9 +123,17 @@
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
</div>
<div class="sidebar-sources-link">
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten">
<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>
<span>Quellen</span>
</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()" title="Feedback senden">
<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="16" x="2" y="4" rx="2"/><path d="m22 7-10 5L2 7"/></svg>
<span>Feedback</span>
</button>
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
-->
<div class="sidebar-stats-mini">
<span id="stat-sources-count">0 Quellen</span> &middot; <span id="stat-articles-count">0 Artikel</span>
</div>
@@ -130,101 +145,12 @@
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">&#9737;</div>
<div class="empty-state-title">Kein Vorfall ausgewählt</div>
<div class="empty-state-text">Erstelle eine neue Lage oder wähle einen bestehenden Vorfall aus der Seitenleiste.</div>
<div class="empty-state-text">Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.</div>
</div>
<!-- Netzwerkanalyse View (hidden by default) -->
<div id="network-view" style="display:none;">
<!-- Header Strip -->
<div class="network-header-strip">
<div class="network-header-row1">
<div class="network-header-left">
<span class="incident-type-badge type-network">Netzwerk</span>
<h2 class="network-header-title" id="network-title"></h2>
<span class="network-update-badge" id="network-update-badge" style="display:none;" onclick="App.regenerateNetwork()">Aktualisierung verfügbar</span>
</div>
<div class="network-header-actions">
<button class="btn btn-primary btn-small" onclick="App.regenerateNetwork()">Neu generieren</button>
<div class="export-dropdown">
<button class="btn btn-secondary btn-small" onclick="this.nextElementSibling.classList.toggle('show')" aria-haspopup="true">Exportieren &#9662;</button>
<div class="export-dropdown-menu" role="menu">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportNetwork('json')">JSON</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportNetwork('csv')">CSV (Kantenliste)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportNetwork('png')">PNG Screenshot</button>
</div>
</div>
<button class="btn btn-danger btn-small" onclick="App.deleteNetworkAnalysis()">Löschen</button>
</div>
</div>
<div class="network-header-meta">
<span id="network-entity-count"></span>
<span id="network-relation-count"></span>
<span id="network-last-generated"></span>
<span id="network-incident-list-text" style="opacity:0.7;"></span>
</div>
</div>
<!-- Progress Bar -->
<div class="network-progress" id="network-progress-bar" style="display:none;">
<div class="network-progress-steps">
<div class="network-progress-step" data-step="entity_extraction">
<div class="network-progress-step-dot"></div>
<span>Entitäten</span>
</div>
<div class="network-progress-connector"></div>
<div class="network-progress-step" data-step="relationship_extraction">
<div class="network-progress-step-dot"></div>
<span>Beziehungen</span>
</div>
<div class="network-progress-connector"></div>
<div class="network-progress-step" data-step="correction">
<div class="network-progress-step-dot"></div>
<span>Korrekturen</span>
</div>
</div>
<div class="network-progress-track">
<div class="network-progress-fill" id="network-progress-fill"></div>
</div>
<div class="network-progress-label" id="network-progress-label">Wird verarbeitet...</div>
</div>
<!-- Graph + Sidebar -->
<div class="network-content">
<div class="network-graph-area" id="network-graph-area">
<div class="network-empty-state">
<div class="network-empty-state-icon">&#9737;</div>
<div class="network-empty-state-text">Graph wird geladen...</div>
</div>
</div>
<div class="network-sidebar">
<div class="network-sidebar-section">
<div class="network-sidebar-section-title">Suche</div>
<input type="text" class="network-search-input" id="network-search" placeholder="Entität suchen...">
</div>
<div class="network-sidebar-section">
<div class="network-sidebar-section-title">Typ-Filter</div>
<div class="network-type-filters" id="network-type-filter-container"></div>
</div>
<div class="network-sidebar-section">
<div class="network-sidebar-section-title">Min. Gewicht: <strong id="network-weight-value">1</strong></div>
<input type="range" class="network-weight-slider" id="network-weight-slider" min="1" max="5" value="1" step="1">
<div class="network-weight-labels"><span>1</span><span>5</span></div>
</div>
<div class="network-sidebar-section" style="border-bottom:none;">
<button class="btn btn-secondary btn-small btn-full" onclick="App.resetNetworkView()" style="margin-bottom:6px;">Filter zurücksetzen</button>
<button class="btn btn-secondary btn-small btn-full" onclick="App.isolateNetworkCluster()">Cluster isolieren</button>
</div>
<div class="network-detail-panel" id="network-detail-panel">
<div class="network-detail-empty">Klicke auf einen Knoten für Details</div>
</div>
</div>
</div>
<!-- Tooltip -->
<div class="network-tooltip" id="network-tooltip"></div>
</div>
<!-- Lagebild (hidden by default) -->
<div id="incident-view" style="display:none;">
@@ -241,18 +167,7 @@
<div class="incident-header-actions">
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
<button class="btn btn-secondary btn-small" id="edit-incident-btn">Bearbeiten</button>
<div class="export-dropdown" id="export-dropdown">
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)" aria-expanded="false" aria-haspopup="true">Exportieren &#9662;</button>
<div class="export-dropdown-menu" id="export-dropdown-menu" role="menu">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','report')">Lagebericht (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','report')">Lagebericht (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.openPdfExportDialog()">PDF exportieren...</button>
</div>
</div>
<button class="btn btn-secondary btn-small" onclick="App.openExportModal()">Bericht exportieren</button>
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
</div>
@@ -280,143 +195,131 @@
</div>
</div>
<!-- Fortschrittsanzeige -->
<div class="progress-bar" id="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Verarbeitungsfortschritt" style="display:none;">
<div class="progress-steps">
<div class="progress-step" id="step-researching">
<div class="progress-step-dot"></div>
<span>Recherche</span>
</div>
<div class="progress-step" id="step-analyzing">
<div class="progress-step-dot"></div>
<span>Analyse</span>
</div>
<div class="progress-step" id="step-factchecking">
<div class="progress-step-dot"></div>
<span>Faktencheck</span>
</div>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-label-container">
<span id="progress-label" class="progress-label">Warte auf Start...</span>
<span id="progress-timer" class="progress-timer"></span>
</div>
<button id="progress-cancel-btn" class="progress-cancel-btn" onclick="App.cancelRefresh()">Abbrechen</button>
<!-- Minimierte Fortschrittsanzeige -->
<div class="progress-mini" id="progress-mini" style="display:none;" onclick="App.openProgressPopup()">
<span class="progress-mini-dot"></span>
<span class="progress-mini-text" id="progress-mini-text">Läuft...</span>
<span class="progress-mini-timer" id="progress-mini-timer"></span>
</div>
<!-- Layout-Toolbar -->
<div class="layout-toolbar" id="layout-toolbar" style="display:none;">
<div class="layout-toggles">
<button class="layout-toggle-btn active" data-tile="lagebild" onclick="LayoutManager.toggleTile('lagebild')" aria-pressed="true">Lagebild</button>
<button class="layout-toggle-btn active" data-tile="faktencheck" onclick="LayoutManager.toggleTile('faktencheck')" aria-pressed="true">Faktencheck</button>
<button class="layout-toggle-btn active" data-tile="quellen" onclick="LayoutManager.toggleTile('quellen')" aria-pressed="true">Quellen</button>
<button class="layout-toggle-btn active" data-tile="timeline" onclick="LayoutManager.toggleTile('timeline')" aria-pressed="true">Timeline</button>
<button class="layout-toggle-btn active" data-tile="karte" onclick="LayoutManager.toggleTile('karte')" aria-pressed="true">Karte</button>
</div>
<button class="btn btn-secondary btn-small" onclick="LayoutManager.reset()">Layout zurücksetzen</button>
<!-- Tab-Navigation -->
<div class="tab-nav" id="tab-nav" style="display:none;">
<button class="tab-btn active" data-tab="zusammenfassung">Neueste Entwicklungen</button>
<button class="tab-btn" data-tab="lagebild">Lagebild</button>
<button class="tab-btn" data-tab="timeline">Ereignis-Timeline</button>
<button class="tab-btn" data-tab="karte">Geografische Verteilung</button>
<button class="tab-btn" data-tab="faktencheck">Faktencheck</button>
<button class="tab-btn" data-tab="pipeline">Analysepipeline</button>
<button class="tab-btn" data-tab="quellen">Quellenübersicht</button>
</div>
<!-- gridstack Dashboard-Grid -->
<div class="grid-stack">
<div class="grid-stack-item" gs-id="lagebild" gs-x="0" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card incident-analysis-summary">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
</div>
<div id="summary-content">
<div id="summary-text" class="summary-text"></div>
</div>
<!-- Tab-Panels -->
<div class="tab-panels">
<div class="tab-panel active" id="panel-zusammenfassung">
<div class="card" id="zusammenfassung-card">
<div class="card-header">
<div class="card-title">Zusammenfassung</div>
</div>
<div id="zusammenfassung-content">
<div id="zusammenfassung-text" class="summary-text" style="padding:8px 16px;"></div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="faktencheck" gs-x="6" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card incident-analysis-factcheck" id="factcheck-card">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Faktencheck', 'factcheck-list')">Faktencheck <span class="info-icon" data-tooltip="Gesichert/Bestätigt = durch mehrere unabhängige Quellen belegt.&#10;&#10;Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert.&#10;&#10;Widerlegt/Umstritten = Quellen widersprechen sich."><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></div>
<div class="fc-filter-bar" id="fc-filters"></div>
</div>
<div class="factcheck-list" id="factcheck-list">
<div class="empty-state" style="padding:20px;">
<div class="empty-state-text">Noch keine Fakten geprüft</div>
<div class="tab-panel" id="panel-lagebild">
<div class="card incident-analysis-summary">
<div class="card-header">
<div class="card-title">Lagebild</div>
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
</div>
<div id="summary-content">
<div id="summary-text" class="summary-text"></div>
</div>
</div>
</div>
<div class="tab-panel" id="panel-timeline">
<div class="card timeline-card">
<div class="card-header">
<div class="card-title">Ereignis-Timeline</div>
<div class="ht-controls">
<div class="ht-filter-group">
<button class="ht-filter-btn active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
<button class="ht-filter-btn" data-filter="articles" onclick="App.setTimelineFilter('articles')" aria-pressed="false">Meldungen</button>
<button class="ht-filter-btn" data-filter="snapshots" onclick="App.setTimelineFilter('snapshots')" aria-pressed="false">Lageberichte</button>
</div>
</div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="quellen" gs-x="0" gs-y="4" gs-w="12" gs-h="2" gs-min-w="6" gs-min-h="2">
<div class="grid-stack-item-content">
<div class="card source-overview-card">
<div class="card-header source-overview-header-toggle" onclick="App.toggleSourceOverview()" role="button" tabindex="0" aria-expanded="false">
<span class="source-overview-chevron" id="source-overview-chevron" title="Aufklappen" aria-hidden="true">&#9656;</span>
<div class="card-title clickable">Quellenübersicht</div>
<button class="btn btn-secondary btn-small source-detail-btn" onclick="event.stopPropagation(); openContentModal('Quellenübersicht', 'source-overview-content')">Detailansicht</button>
</div>
<div class="source-overview-subheader" onclick="App.toggleSourceOverview()" role="button">
<span class="source-overview-header-stats" id="source-overview-header-stats"></span>
</div>
<div id="source-overview-content" style="display:none;"></div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="timeline" gs-x="0" gs-y="5" gs-w="12" gs-h="4" gs-min-w="6" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card timeline-card">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
<div class="ht-controls">
<div class="ht-filter-group">
<button class="ht-filter-btn active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
<button class="ht-filter-btn" data-filter="articles" onclick="App.setTimelineFilter('articles')" aria-pressed="false">Meldungen</button>
<button class="ht-filter-btn" data-filter="snapshots" onclick="App.setTimelineFilter('snapshots')" aria-pressed="false">Lageberichte</button>
</div>
<span class="ht-count" id="article-count"></span>
<div class="ht-range-group">
<button class="ht-range-btn" data-range="24h" onclick="App.setTimelineRange('24h')" aria-pressed="false">24h</button>
<button class="ht-range-btn" data-range="7d" onclick="App.setTimelineRange('7d')" aria-pressed="false">7T</button>
<button class="ht-range-btn active" data-range="all" onclick="App.setTimelineRange('all')" aria-pressed="true">Alles</button>
</div>
<label for="timeline-search" class="sr-only">Timeline durchsuchen</label>
<input type="text" id="timeline-search" class="timeline-filter-input" placeholder="Suche..." oninput="App.debouncedRerenderTimeline()">
<span class="ht-count" id="article-count"></span>
<div class="ht-range-group">
<button class="ht-range-btn" data-range="24h" onclick="App.setTimelineRange('24h')" aria-pressed="false">24h</button>
<button class="ht-range-btn" data-range="7d" onclick="App.setTimelineRange('7d')" aria-pressed="false">7T</button>
<button class="ht-range-btn active" data-range="all" onclick="App.setTimelineRange('all')" aria-pressed="true">Alles</button>
</div>
<label for="timeline-search" class="sr-only">Timeline durchsuchen</label>
<input type="text" id="timeline-search" class="timeline-filter-input" placeholder="Suche..." oninput="App.debouncedRerenderTimeline()">
</div>
<div id="timeline" class="ht-timeline-container">
<div class="ht-empty">Noch keine Meldungen</div>
</div>
<div id="timeline" class="ht-timeline-container">
<div class="ht-empty">Noch keine Meldungen</div>
</div>
</div>
</div>
<div class="tab-panel" id="panel-karte">
<div class="card map-card">
<div class="card-header">
<div class="card-title">Geografische Verteilung</div>
<span class="map-stats" id="map-stats"></span>
<div class="card-header-actions">
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
</div>
</div>
<div class="map-container" id="map-container">
<div class="map-empty" id="map-empty">Keine Orte erkannt</div>
</div>
</div>
</div>
<div class="tab-panel" id="panel-faktencheck">
<div class="card incident-analysis-factcheck" id="factcheck-card">
<div class="card-header">
<div class="card-title">Faktencheck <span class="info-icon" data-tooltip="Gesichert/Bestätigt = durch mehrere unabhängige Quellen belegt.&#10;&#10;Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert.&#10;&#10;Widerlegt/Umstritten = Quellen widersprechen sich."><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></div>
<div class="fc-filter-bar" id="fc-filters"></div>
</div>
<div class="factcheck-list" id="factcheck-list">
<div class="empty-state" style="padding:20px;">
<div class="empty-state-text">Noch keine Fakten geprüft</div>
</div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="karte" gs-x="0" gs-y="9" gs-w="12" gs-h="8" gs-min-w="6" gs-min-h="3">
<div class="grid-stack-item-content">
<div class="card map-card">
<div class="card-header">
<div class="card-title">Geografische Verteilung</div>
<span class="map-stats" id="map-stats"></span>
<div class="card-header-actions">
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
<button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
</div>
</div>
<div class="map-container" id="map-container">
<div class="map-empty" id="map-empty">Keine Orte erkannt</div>
</div>
<div class="tab-panel" id="panel-pipeline">
<div class="card pipeline-card" id="pipeline-card">
<div class="card-header">
<div class="card-title">Analysepipeline</div>
<span class="pipeline-header-meta" id="pipeline-header-meta"></span>
</div>
<div class="pipeline-body">
<div class="pipeline-stage" id="pipeline-stage" aria-label="Analysepipeline-Visualisierung">
<div class="pipeline-empty" id="pipeline-empty">Noch nie aktualisiert. Starte den ersten Refresh.</div>
</div>
<aside class="pipeline-sidenote" id="pipeline-sidenote" hidden>
Recherche-Lagen werden mehrfach evaluiert, um das Bild Schritt für Schritt aufzubauen.
</aside>
</div>
</div>
</div>
<div class="tab-panel" id="panel-quellen">
<div class="card source-overview-card">
<div class="card-header">
<div class="card-title">Quellenübersicht</div>
<span class="source-overview-header-stats" id="source-overview-header-stats"></span>
</div>
<div id="source-overview-content"></div>
</div>
</div>
</div>
<!-- Parkplatz für ausgeblendete Kacheln -->
<div id="tile-parking" style="display:none;"></div>
</div>
</main>
</div>
@@ -425,7 +328,7 @@
<div class="modal-overlay" id="modal-new" role="dialog" aria-modal="true" aria-labelledby="modal-new-title">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-new-title">Neue Lage anlegen</div>
<div class="modal-title" id="modal-new-title">Neuen Fall anlegen</div>
<button class="modal-close" onclick="closeModal('modal-new')" aria-label="Schließen">&times;</button>
</div>
<form id="new-incident-form">
@@ -435,14 +338,21 @@
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid">
</div>
<div class="form-group">
<label for="inc-description">Beschreibung / Kontext</label>
<div class="description-label-row">
<label for="inc-description">Beschreibung / Kontext <span class="info-icon tooltip-below" id="description-info-icon" data-tooltip="Beschreibe den Vorfall möglichst genau: Was ist passiert? Wo? Wer ist beteiligt? Je präziser, desto bessere Ergebnisse."><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>
<button type="button" class="btn btn-secondary btn-small" id="btn-enhance-description" onclick="App.generateDescription()" disabled>
<span id="enhance-btn-text">Beschreibung generieren</span>
<span id="enhance-spinner" class="spinner-inline" style="display:none;"></span>
</button>
</div>
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
</div>
<div class="form-group">
<label for="inc-type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc">Live-Monitoring Ereignis beobachten</option>
<option value="research">Analyse — Thema recherchieren</option>
<option value="adhoc">Live-Monitoring : Ereignis beobachten</option>
<option value="research">Recherche : Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
@@ -470,7 +380,7 @@
<label class="toggle-label">
<input type="checkbox" id="inc-visibility" checked>
<span class="toggle-switch"></span>
<span class="toggle-text" id="visibility-text">Öffentlich für alle Nutzer sichtbar</span>
<span class="toggle-text" id="visibility-text">Öffentlich : für alle Nutzer sichtbar</span>
</label>
</div>
</div>
@@ -493,6 +403,10 @@
</select>
</div>
</div>
<div class="form-group conditional-field" id="refresh-starttime-field">
<label for="inc-refresh-starttime">Erste Aktualisierung um <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><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>
<input type="time" id="inc-refresh-starttime" value="07:00" required>
</div>
<div class="form-group">
<label for="inc-retention">Aufbewahrung (Tage) <span class="info-icon tooltip-below" data-tooltip="Nach Ablauf wird die Lage automatisch archiviert. 0 = unbegrenzt aufbewahren."><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>
<input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt">
@@ -726,34 +640,6 @@
<!-- Modal: Neue Netzwerkanalyse -->
<div class="modal-overlay" id="modal-network-new" role="dialog" aria-modal="true" aria-labelledby="modal-network-new-title">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-network-new-title">Neue Netzwerkanalyse</div>
<button class="modal-close" onclick="closeModal('modal-network-new')" aria-label="Schließen">&times;</button>
</div>
<form onsubmit="App.submitNetworkAnalysis(event); return false;">
<div class="modal-body">
<div class="form-group">
<label for="network-name">Name der Analyse</label>
<input type="text" id="network-name" required placeholder="z.B. Irankonflikt-Netzwerk">
</div>
<div class="form-group">
<label>Lagen auswählen</label>
<div class="network-incident-list">
<input type="text" class="network-incident-search" id="network-incident-search" placeholder="Lagen durchsuchen...">
<div id="network-incident-options"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-network-new')">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="network-submit-btn">Analyse starten</button>
</div>
</form>
</div>
</div>
<!-- Tutorial -->
<div class="tutorial-overlay" id="tutorial-overlay">
<div class="tutorial-spotlight" id="tutorial-spotlight"></div>
@@ -765,19 +651,17 @@
<div class="toast-container" id="toast-container" aria-live="polite" aria-atomic="true"></div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack-all.js"></script>
<script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/api.js?v=20260316c"></script>
<script src="/static/js/api.js?v=20260423a"></script>
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260316d"></script>
<script src="/static/js/components.js?v=20260427a"></script>
<script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/app.js?v=20260316b"></script>
<script src="/static/js/api_network.js?v=20260316a"></script>
<script src="/static/js/network-graph.js?v=20260316a"></script>
<script src="/static/js/app_network.js?v=20260316a"></script>
<script src="/static/js/pipeline.js?v=20260501i"></script>
<script src="/static/js/app.js?v=20260501h"></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=20260316i"></script>
<script src="/static/js/chat.js?v=20260422a"></script>
<script>document.addEventListener("DOMContentLoaded",function(){Chat.init();Tutorial.init()});</script>
<!-- Map Fullscreen Overlay -->
@@ -792,28 +676,79 @@
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
</div>
<!-- PDF Export Dialog -->
<div class="modal-overlay" id="modal-pdf-export" role="dialog" aria-modal="true" aria-labelledby="pdf-export-title">
<!-- Export Modal -->
<div class="modal-overlay" id="modal-export" role="dialog" aria-modal="true">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3 id="pdf-export-title">PDF exportieren</h3>
<button class="modal-close" onclick="closeModal('modal-pdf-export')" aria-label="Schliessen">&times;</button>
<h3>Bericht exportieren</h3>
<button class="modal-close" onclick="closeModal('modal-export')">&times;</button>
</div>
<div class="modal-body" style="padding:20px;">
<p style="margin:0 0 16px;font-size:13px;color:var(--text-secondary);">Kacheln fuer den Export auswaehlen:</p>
<div id="pdf-export-tiles" style="display:flex;flex-direction:column;gap:10px;">
<label class="pdf-tile-option"><input type="checkbox" value="lagebild" checked><span>Lagebild</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="quellen" checked><span>Quellenübersicht</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="faktencheck" checked><span>Faktencheck</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="timeline"><span>Ereignis-Timeline</span></label>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Bereiche</label>
<label class="export-radio"><input type="checkbox" name="export-section" value="zusammenfassung" checked><span>Zusammenfassung</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="bericht" checked><span>Recherchebericht / Lagebild</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="faktencheck" checked><span>Faktencheck</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="quellen" checked><span>Quellen</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Format</label>
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-pdf-export')">Abbrechen</button>
<button class="btn btn-primary" onclick="App.executePdfExport()">Exportieren</button>
<button class="btn btn-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
<button class="btn btn-primary" id="export-submit-btn" onclick="App.submitExport()">Exportieren</button>
</div>
</div>
</div>
<!-- Fortschritts-Popup -->
<div class="progress-overlay" id="progress-overlay" style="display:none;">
<div class="progress-popup" id="progress-popup">
<div class="progress-popup-header">
<span class="progress-popup-title" id="progress-popup-title">Aktualisierung läuft</span>
<span class="progress-popup-timer" id="progress-popup-timer"></span>
<button class="progress-popup-minimize" id="progress-popup-minimize" style="display:none;" onclick="App.minimizeProgress()" title="Minimieren">&minus;</button>
</div>
<div class="progress-popup-body">
<div class="progress-popup-pass" id="progress-popup-pass" style="display:none;"></div>
<div class="pipeline-mini" id="progress-pipeline-mini" aria-label="Analyseschritte"></div>
<div class="progress-checklist" id="progress-checklist" style="display:none;">
<div class="progress-check-item" data-step="queued">
<span class="progress-check-icon"></span>
<span class="progress-check-label">In Warteschlange</span>
<span class="progress-check-detail"></span>
</div>
<div class="progress-check-item" data-step="researching">
<span class="progress-check-icon"></span>
<span class="progress-check-label">Quellen werden durchsucht</span>
<span class="progress-check-detail"></span>
</div>
<div class="progress-check-item" data-step="analyzing">
<span class="progress-check-icon"></span>
<span class="progress-check-label">Meldungen werden analysiert</span>
<span class="progress-check-detail"></span>
</div>
<div class="progress-check-item" data-step="factchecking">
<span class="progress-check-icon"></span>
<span class="progress-check-label">Faktencheck läuft</span>
<span class="progress-check-detail"></span>
</div>
</div>
<div class="progress-complete-summary" id="progress-complete-summary" style="display:none;"></div>
</div>
<div class="progress-popup-footer">
<button class="progress-cancel-btn" id="progress-cancel-btn" onclick="App.cancelRefresh()">Abbrechen</button>
</div>
</div>
</div>
<script src="/static/js/update-system.js"></script>
<script src="/static/js/ai-disclaimer.js"></script>
</body>
</html>

8
src/static/favicon.svg Normale Datei
Datei anzeigen

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 400 497" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="svgg">
<path id="rechts" d="M212.575,238.576C212.984,240.67 223.048,241.002 270.154,240.533C349.694,239.739 344.481,239.31 346.236,243.942C347.823,248.13 347.264,250.927 338.778,272.292C333.041,286.737 321.692,301.671 304.569,327.057C262.704,389.124 258.243,380.556 257.465,379.844C256.548,379.007 256.695,378.153 256.7,377.409C256.827,359.293 254.573,273.452 254.549,270.937C254.525,268.422 254.116,268.891 229.156,268.982C211.282,269.047 211.756,268.669 211.925,271.847C211.971,272.701 212.094,316.69 212.2,369.6C212.306,422.51 212.487,468.568 212.604,469.063C213.014,470.81 224.336,462 224.6,462C224.864,462 237.107,453.265 241.4,450.384C242.5,449.646 244.343,448.313 245.496,447.421C246.648,446.53 248.865,444.9 250.421,443.8C251.978,442.7 255.169,440.115 257.513,438.055C259.857,435.996 262.771,433.605 263.988,432.743C267.489,430.261 269.974,428.216 270.637,427.269C270.973,426.789 271.767,426.127 272.4,425.8C273.034,425.472 273.862,424.68 274.24,424.04C274.618,423.399 275.574,422.512 276.364,422.067C277.741,421.292 287.002,412.973 290.077,409.749C290.89,408.897 293.68,406.009 296.277,403.331C303.179,396.216 308.766,389.886 310.684,387.009C311.611,385.619 312.782,384.149 313.286,383.741C313.791,383.334 314.523,382.55 314.913,382C315.304,381.45 316.113,380.353 316.711,379.562C317.31,378.771 318.552,377.132 319.471,375.919C320.389,374.706 321.709,373.103 322.403,372.357C324.097,370.534 325.597,368.32 327.217,365.252C327.957,363.85 329.057,362.338 329.66,361.892C330.264,361.446 331.622,359.655 332.679,357.912C333.735,356.168 335.453,353.696 336.496,352.417C337.539,351.139 338.935,348.947 339.599,347.546C341.424,343.695 344.598,338.004 345.689,336.626C347.172,334.754 348.692,331.944 348.986,330.528C349.132,329.828 349.51,329.041 349.826,328.779C350.142,328.517 350.4,328.069 350.4,327.784C350.4,327.499 351.048,326.045 351.84,324.552C352.632,323.059 353.784,320.479 354.401,318.819C355.017,317.159 356.416,314.072 357.509,311.96C358.602,309.848 359.894,306.968 360.38,305.56C360.866,304.152 361.593,302.46 361.995,301.8C362.398,301.14 362.941,299.795 363.203,298.812C363.464,297.828 363.931,296.663 364.239,296.223C364.548,295.782 364.8,295.078 364.8,294.658C364.8,293.56 367.089,287.051 368.23,284.904C368.764,283.901 369.201,282.793 369.202,282.44C369.204,282.088 369.46,281.312 369.771,280.715C370.082,280.118 370.552,278.588 370.814,277.315C371.076,276.042 371.715,273.867 372.234,272.482C372.753,271.097 373.442,268.667 373.765,267.082C374.657,262.705 375.074,261.226 376.185,258.503C376.746,257.13 377.395,254.61 377.628,252.903C377.861,251.196 386.4,207.294 386.4,202.415C386.4,200.114 384.943,198.138 382.973,197.769C382.197,197.623 390.698,196.027 262.4,197.136L256.297,196.493C254.923,195.188 254.409,193.392 254.634,190.691C255.021,186.052 255.075,102.153 254.699,90.2C254.256,76.132 254.359,75.232 256.566,73.785C257.5,73.174 257.724,73.166 258.9,73.706C259.615,74.035 343.437,105.997 345.2,108.641L346.2,110.142L346.246,163.984L347.17,164.968L348.095,165.953L367.317,165.835L386.539,165.718L387.711,164.406L388.883,163.095L388.646,155.847C388.515,151.861 388.304,143.29 388.176,136.8C387.97,126.347 389.116,102.223 388.883,92.984C388.587,81.212 385.041,79.623 381.162,77.313C378.036,75.451 212.403,10.83 212.49,12.505" style="fill:rgb(200,168,81);"/>
<path id="links" d="M31.8,72.797C19.193,77.854 16.869,77.149 16.354,86.093C16.177,89.171 13.694,109.47 13.373,112C11.292,128.389 11.075,175.356 12.999,192.8C13.326,195.77 15.755,217.626 17.524,225.4C17.975,227.38 21.242,245.556 21.798,247.6C23.196,252.741 27.444,269.357 28.368,273C29.454,277.277 33.845,288.636 34.632,290.326C35.42,292.017 39.017,301.259 39.364,301.931C39.973,303.107 41.279,306.405 42.799,310.6C43.879,313.58 46.904,319.091 47.546,320.62C48.78,323.561 51.339,328.992 51.965,330C52.17,330.33 53.466,332.67 54.845,335.2C56.223,337.73 65.855,353.259 67.765,356.052C72.504,362.981 75.544,366.754 76.46,368.119C78.119,370.593 79.488,372.185 85.821,379C87.66,380.98 89.758,383.356 90.483,384.279C92.003,386.218 92.035,386.23 93.151,385.3C94.267,384.37 94.041,384.013 94.036,382.593C94.015,376.905 94.025,351.182 94.025,351.182C94.062,315.081 94.745,313.16 93.752,308.626C92.302,301.997 88.001,300.043 80.439,284.793C71.474,266.714 65.169,255.803 62.016,248.485C61.011,246.153 59.289,240.91 61.521,240.882C65.215,240.836 143.575,240.107 144.382,240.673C145.808,241.671 146.494,243.516 146.346,245.959C146.058,250.736 146.217,438.282 146.511,439.663C146.825,441.137 153.946,447.096 162.193,452.924C177.223,463.547 187.111,469.578 187.956,468.458C189.091,466.954 188.058,10.288 188.006,12.482M146.001,134.292C145.999,164.821 146.043,190.718 146.099,191.84C146.336,196.617 147.019,196.45 127.622,196.354C106.312,196.249 58.054,196.89 58.054,196.89L57.06,195.896C55.315,194.152 55.678,132.49 55.766,126C56.004,108.467 56.656,110.707 66.745,106.586C70.345,105.116 134.261,79.128 135.708,78.566C146.998,74.183 145.972,74.295 146.055,76.768" style="fill:rgb(10,24,50);"/>
</g>
</svg>

Nachher

Breite:  |  Höhe:  |  Größe: 5.3 KiB

Datei anzeigen

@@ -4,10 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>(function(){var t=localStorage.getItem('osint_theme');if(t)document.documentElement.setAttribute('data-theme',t);try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<title>AegisSight Monitor - Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -35,20 +33,20 @@
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
</form>
<!-- Schritt 2: Code eingeben -->
<form id="code-form" style="display:none;">
<p style="color: var(--text-secondary); margin: 0 0 16px 0; font-size: 14px;">
Ein 6-stelliger Code wurde an <strong id="sent-email"></strong> gesendet.
</p>
<div class="form-group">
<label for="code">Code eingeben</label>
<input type="text" id="code" name="code" autocomplete="one-time-code" required aria-required="true"
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;">
<!-- Schritt 2: Link gesendet -->
<div id="link-sent" style="display:none;">
<div style="text-align:center; padding: 20px 0;">
<div style="font-size: 40px; margin-bottom: 16px;">&#9993;</div>
<p style="color: var(--text-secondary); margin: 0 0 8px 0; font-size: 14px;">
Ein Anmelde-Link wurde an
</p>
<p style="color: var(--accent); font-weight: 600; font-size: 16px; margin: 0 0 16px 0;" id="sent-email"></p>
<p style="color: var(--text-secondary); margin: 0 0 24px 0; font-size: 14px;">
gesendet. Bitte prüfen Sie Ihr Postfach und klicken Sie auf den Link.
</p>
</div>
<button type="submit" class="btn btn-primary btn-full" id="code-btn">Verifizieren</button>
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurück</button>
</form>
<button type="button" class="btn btn-secondary btn-full" id="back-btn">Andere E-Mail verwenden</button>
</div>
<div style="text-align:center;margin-top:16px;">
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">&#9788;</button>
@@ -148,11 +146,10 @@
throw new Error(data.detail || 'Anfrage fehlgeschlagen');
}
// Zu Code-Eingabe wechseln
// Link-gesendet-Hinweis anzeigen
document.getElementById('email-form').style.display = 'none';
document.getElementById('code-form').style.display = 'block';
document.getElementById('link-sent').style.display = 'block';
document.getElementById('sent-email').textContent = currentEmail;
document.getElementById('code').focus();
} catch (err) {
errorEl.textContent = err.message;
@@ -163,49 +160,11 @@
}
});
// Schritt 2: Code verifizieren
document.getElementById('code-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
const btn = document.getElementById('code-btn');
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Wird geprüft...';
try {
const response = await fetch('/api/auth/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: currentEmail,
code: document.getElementById('code').value.trim(),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Verifizierung fehlgeschlagen');
}
const data = await response.json();
localStorage.setItem('osint_token', data.access_token);
localStorage.setItem('osint_username', data.username);
window.location.href = '/dashboard';
} catch (err) {
errorEl.textContent = err.message;
errorEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Verifizieren';
}
});
// Zurück-Button
document.getElementById('back-btn').addEventListener('click', () => {
document.getElementById('code-form').style.display = 'none';
document.getElementById('link-sent').style.display = 'none';
document.getElementById('email-form').style.display = 'block';
document.getElementById('login-error').style.display = 'none';
document.getElementById('code').value = '';
});
</script>
</body>

195
src/static/js/ai-disclaimer.js Normale Datei
Datei anzeigen

@@ -0,0 +1,195 @@
/**
* AI-Hallucination-Disclaimer fuer den AegisSight Monitor.
*
* Zeigt:
* 1) Beim ersten Besuch (oder bei neuem v-Bump) ein Modal mit Hinweisen
* zur Fehlbarkeit von KI-Modellen.
* 2) Im Header-User-Dropdown immer einen Eintrag "Ueber KI-Inhalte",
* ueber den der User das Modal jederzeit erneut oeffnen kann.
*
* Persistenz:
* localStorage 'aegis_ai_disclaimer_seen' -> Versionsstring (z.B. "v1").
* Wenn die Version sich aendert (Wortlaut-Update), erscheint das Modal
* beim naechsten Login erneut.
*/
(function () {
'use strict';
const STORAGE_KEY = 'aegis_ai_disclaimer_seen';
const CURRENT_VERSION = 'v1';
// ---- DOM-Helpers (analog zu update-system.js) ----
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
for (const k in (attrs || {})) {
if (k === 'class') e.className = attrs[k];
else if (k === 'html') e.innerHTML = attrs[k];
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
else e.setAttribute(k, attrs[k]);
}
for (const c of children) {
if (c == null) continue;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return e;
}
function injectStyles() {
if (document.getElementById('aegis-aidisc-styles')) return;
const css = `
#aegis-aidisc-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 99998;
backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; padding: 24px;
animation: aegis-aidisc-fade 0.25s ease;
}
@keyframes aegis-aidisc-fade { from { opacity: 0; } to { opacity: 1; } }
#aegis-aidisc-modal {
background: var(--bg-card);
color: var(--text-primary);
border-radius: 14px;
border: 1px solid var(--border);
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
font-family: 'Inter', -apple-system, sans-serif;
max-width: 580px; width: 100%; max-height: 85vh; overflow: hidden;
display: flex; flex-direction: column;
}
#aegis-aidisc-modal header {
padding: 22px 28px 18px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
#aegis-aidisc-modal header svg { color: var(--accent); flex-shrink: 0; }
#aegis-aidisc-modal h2 { margin: 0; color: var(--accent); font-size: 1.25rem; font-weight: 700; }
#aegis-aidisc-modal .body { padding: 18px 28px; overflow-y: auto; line-height: 1.55; }
#aegis-aidisc-modal .body p { margin: 0 0 12px; color: var(--text-primary); font-size: 0.94rem; }
#aegis-aidisc-modal .body strong { color: var(--accent); }
#aegis-aidisc-modal .body ul { margin: 8px 0 14px; padding-left: 22px; }
#aegis-aidisc-modal .body li { margin-bottom: 6px; color: var(--text-secondary); font-size: 0.92rem; }
#aegis-aidisc-modal .footnote {
margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--border);
color: var(--text-tertiary); font-size: 0.82rem;
}
#aegis-aidisc-modal footer {
padding: 14px 28px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}
#aegis-aidisc-modal footer button {
background: var(--accent); color: #fff; border: 0; padding: 10px 22px;
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
cursor: pointer;
}
#aegis-aidisc-modal footer button:hover { background: var(--accent-hover); }
#aegis-aidisc-modal footer button.secondary {
background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
}
#aegis-aidisc-modal footer button.secondary:hover {
background: var(--bg-hover, rgba(255,255,255,0.04)); color: var(--text-primary);
}`;
document.head.appendChild(el('style', { id: 'aegis-aidisc-styles', html: css }));
}
// ---- Modal-Aufbau ----
function buildModal(opts) {
const isFromUser = !!(opts && opts.fromUserAction);
// Lucide info-Icon (gleiches Pattern wie .info-icon im Repo)
const headerIcon = el('span', {
html: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" '
+ '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>'
});
const body = el('div', { class: 'body' });
body.appendChild(el('p', null,
'Der AegisSight Monitor nutzt Künstliche Intelligenz '
+ 'zur Analyse, Übersetzung und Zusammenfassung von Nachrichten.'));
const warn = el('p');
warn.innerHTML = '<strong>KI-Modelle können Fehler machen</strong> '
+ '(sogenannte „Halluzinationen"): erfundene Details, falsche Verbindungen oder '
+ 'ungenaue Zusammenfassungen sind möglich, auch wenn der Text plausibel klingt.';
body.appendChild(warn);
body.appendChild(el('p', null, 'Wir empfehlen daher:'));
body.appendChild(el('ul', null,
el('li', null, 'Wichtige Informationen mit den verlinkten Quellen verifizieren'),
el('li', null, 'Bei kritischen Entscheidungen die Originalartikel prüfen'),
el('li', null, 'Faktenchecks als Hinweis verstehen, nicht als endgültige Wahrheit')
));
body.appendChild(el('p', { class: 'footnote' },
'Diesen Hinweis findest du jederzeit wieder im Menü oben rechts unter „Über KI-Inhalte".'));
const closeAndStore = () => {
try { localStorage.setItem(STORAGE_KEY, CURRENT_VERSION); } catch (e) {}
overlay.remove();
document.removeEventListener('keydown', escHandler);
};
const closeOnly = () => {
overlay.remove();
document.removeEventListener('keydown', escHandler);
};
const footer = el('footer', null);
if (!isFromUser) {
footer.appendChild(el('button', { class: 'secondary', onclick: closeOnly }, 'Später nochmal'));
}
footer.appendChild(el('button', { onclick: closeAndStore }, 'Verstanden'));
const overlay = el('div', { id: 'aegis-aidisc-overlay' },
el('div', { id: 'aegis-aidisc-modal' },
el('header', null, headerIcon, el('h2', null, 'Hinweis zu KI-generierten Inhalten')),
body,
footer
)
);
function escHandler(ev) {
if (ev.key === 'Escape' && document.getElementById('aegis-aidisc-overlay')) {
// ESC = wie "Verstanden" beim erstmaligen Anzeigen, sonst nur schliessen
if (isFromUser) closeOnly(); else closeAndStore();
}
}
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) {
if (isFromUser) closeOnly(); else closeAndStore();
}
});
document.addEventListener('keydown', escHandler);
return overlay;
}
function show(opts) {
if (document.getElementById('aegis-aidisc-overlay')) return;
injectStyles();
document.body.appendChild(buildModal(opts));
}
function init() {
// Nur auf der Dashboard-Seite zeigen, nicht auf der Login-Seite
if (!document.body || document.body.classList.contains('login-page')) return;
injectStyles();
let seenVersion = '';
try { seenVersion = localStorage.getItem(STORAGE_KEY) || ''; } catch (e) {}
if (seenVersion !== CURRENT_VERSION) {
// Etwas verzoegern, damit Hauptdashboard sichtbar ist bevor Modal kommt
setTimeout(() => show({ fromUserAction: false }), 600);
}
}
// Globaler Zugriff zum manuellen Oeffnen aus dem Header-Dropdown
window.AIDisclaimer = {
show: () => show({ fromUserAction: true }),
VERSION: CURRENT_VERSION,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

Datei anzeigen

@@ -1,6 +1,16 @@
/**
* API-Client für den OSINT Lagemonitor.
*/
class ApiError extends Error {
constructor(status, detail) {
super(detail || `Fehler ${status}`);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
const API = {
baseUrl: '/api',
@@ -12,10 +22,15 @@ const API = {
};
},
async _request(method, path, body = null) {
async _request(method, path, body = null, externalSignal = null) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
// Externen Abort weiterleiten an internen Controller
if (externalSignal) {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
const options = {
method,
headers: this._getHeaders(),
@@ -52,7 +67,30 @@ const API = {
} else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail);
}
throw new Error(detail || `Fehler ${response.status}`);
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
const licStatus = response.headers.get('X-License-Status');
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
if (!App.user) App.user = {};
App.user.read_only = true;
App.user.read_only_reason = licStatus;
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
let text = 'Nur Lesezugriff';
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
warningEl.textContent = text;
warningEl.classList.add('visible');
}
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
if (typeof UI !== 'undefined' && UI.showToast) {
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
}
}
throw new ApiError(response.status, detail);
}
if (response.status === 204) return null;
@@ -70,6 +108,10 @@ const API = {
return this._request('GET', `/incidents${query}`);
},
enhanceDescription(title, description, type, signal = null) {
return this._request('POST', '/incidents/enhance-description', { title, description, type }, signal);
},
createIncident(data) {
return this._request('POST', '/incidents', data);
},
@@ -82,6 +124,10 @@ const API = {
return this._request('GET', `/incidents/${id}`);
},
getIncidentSources(id) {
return this._request('GET', `/incidents/${id}/sources`);
},
updateIncident(id, data) {
return this._request('PUT', `/incidents/${id}`, data);
},
@@ -90,18 +136,42 @@ const API = {
return this._request('DELETE', `/incidents/${id}`);
},
getArticles(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles`);
getArticles(incidentId, { limit = 500, offset = 0, search = null } = {}) {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
if (search) params.set('search', search);
return this._request('GET', `/incidents/${incidentId}/articles?${params.toString()}`);
},
getArticlesSourcesSummary(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles/sources-summary`);
},
getArticlesTimelineBuckets(incidentId, granularity = 'day') {
return this._request('GET', `/incidents/${incidentId}/articles/timeline-buckets?granularity=${encodeURIComponent(granularity)}`);
},
getFactChecks(incidentId) {
return this._request('GET', `/incidents/${incidentId}/factchecks`);
},
getPipeline(incidentId) {
return this._request('GET', `/incidents/${incidentId}/pipeline`);
},
getSnapshots(incidentId) {
return this._request('GET', `/incidents/${incidentId}/snapshots`);
},
getSnapshot(incidentId, snapshotId) {
return this._request('GET', `/incidents/${incidentId}/snapshots/${snapshotId}`);
},
searchSnapshots(incidentId, query) {
return this._request('GET', `/incidents/${incidentId}/snapshots/search?q=${encodeURIComponent(query)}`);
},
getLocations(incidentId) {
return this._request('GET', `/incidents/${incidentId}/locations`);
},
@@ -228,10 +298,25 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportIncident(id, format, scope) {
exportReport(id, format, scope, sections) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
if (sections && sections.length > 0) {
url += `&sections=${sections.join(',')}`;
} else if (scope) {
url += `&scope=${scope}`;
}
return fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
},
// --- Global Admin: Org-Wechsel (herausnehmbar) ---
listOrganizations() {
return this._request('GET', '/auth/organizations');
},
switchOrg(organizationId) {
return this._request('POST', '/auth/switch-org', { organization_id: organizationId });
},
};

Datei anzeigen

@@ -1,43 +0,0 @@
/**
* Netzwerkanalyse API-Methoden — werden zum API-Objekt hinzugefügt.
*/
// Netzwerkanalysen
API.listNetworkAnalyses = function() {
return this._request('GET', '/network-analyses');
};
API.createNetworkAnalysis = function(data) {
return this._request('POST', '/network-analyses', data);
};
API.getNetworkAnalysis = function(id) {
return this._request('GET', '/network-analyses/' + id);
};
API.getNetworkGraph = function(id) {
return this._request('GET', '/network-analyses/' + id + '/graph');
};
API.regenerateNetwork = function(id) {
return this._request('POST', '/network-analyses/' + id + '/regenerate');
};
API.checkNetworkUpdate = function(id) {
return this._request('GET', '/network-analyses/' + id + '/check-update');
};
API.updateNetworkAnalysis = function(id, data) {
return this._request('PUT', '/network-analyses/' + id, data);
};
API.deleteNetworkAnalysis = function(id) {
return this._request('DELETE', '/network-analyses/' + id);
};
API.exportNetworkAnalysis = function(id, format) {
var token = localStorage.getItem('osint_token');
return fetch(this.baseUrl + '/network-analyses/' + id + '/export?format=' + format, {
headers: { 'Authorization': 'Bearer ' + token },
});
};

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -1,454 +0,0 @@
/**
* Netzwerkanalyse-Erweiterungen für App-Objekt.
* Wird nach app.js geladen und erweitert App um Netzwerk-Funktionalität.
*/
// State-Erweiterung
App.networkAnalyses = [];
App.currentNetworkId = null;
App._networkGenerating = new Set();
/**
* Netzwerkanalysen laden und Sidebar rendern.
*/
App.loadNetworkAnalyses = async function() {
try {
this.networkAnalyses = await API.listNetworkAnalyses();
} catch (e) {
console.warn('Netzwerkanalysen laden fehlgeschlagen:', e);
this.networkAnalyses = [];
}
this.renderNetworkSidebar();
};
/**
* Netzwerkanalysen-Sektion in der Sidebar rendern.
*/
App.renderNetworkSidebar = function() {
var container = document.getElementById('network-analyses-list');
if (!container) return;
var countEl = document.getElementById('count-network-analyses');
if (countEl) countEl.textContent = '(' + this.networkAnalyses.length + ')';
if (this.networkAnalyses.length === 0) {
container.innerHTML = '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Keine Netzwerkanalysen</div>';
return;
}
var self = this;
container.innerHTML = this.networkAnalyses.map(function(na) {
var isActive = na.id === self.currentNetworkId;
var statusClass = na.status === 'generating' ? 'generating' : (na.status === 'error' ? 'error' : 'ready');
var countText = na.status === 'ready' ? (na.entity_count + ' / ' + na.relation_count) : na.status === 'generating' ? '...' : '';
return '<div class="sidebar-network-item' + (isActive ? ' active' : '') + '" onclick="App.selectNetworkAnalysis(' + na.id + ')">' +
'<svg class="network-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5" y2="16"/><line x1="12" y1="8" x2="19" y2="16"/></svg>' +
'<span class="network-item-name" title="' + _escHtml(na.name) + '">' + _escHtml(na.name) + '</span>' +
'<span class="network-item-count">' + countText + '</span>' +
'<span class="network-status-dot ' + statusClass + '"></span>' +
'</div>';
}).join('');
};
/**
* Netzwerkanalyse auswählen und anzeigen.
*/
App.selectNetworkAnalysis = async function(id) {
this.currentNetworkId = id;
this.currentIncidentId = null;
localStorage.removeItem('selectedIncidentId');
localStorage.setItem('selectedNetworkId', id);
// Views umschalten
document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'none';
document.getElementById('network-view').style.display = 'flex';
// Sidebar aktualisieren
this.renderSidebar();
this.renderNetworkSidebar();
// Analyse laden
try {
var analysis = await API.getNetworkAnalysis(id);
this._renderNetworkHeader(analysis);
if (analysis.status === 'ready') {
this._hideNetworkProgress();
var graphData = await API.getNetworkGraph(id);
NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData);
// Update-Check
try {
var updateCheck = await API.checkNetworkUpdate(id);
var badge = document.getElementById('network-update-badge');
if (badge) badge.style.display = updateCheck.has_update ? 'inline-flex' : 'none';
} catch (e) { /* ignorieren */ }
} else if (analysis.status === 'generating') {
this._showNetworkProgress('entity_extraction', 0);
} else if (analysis.status === 'error') {
this._hideNetworkProgress();
var graphArea = document.getElementById('network-graph-area');
if (graphArea) graphArea.innerHTML = '<div class="network-empty-state"><div class="network-empty-state-icon">&#9888;</div><div class="network-empty-state-text">Fehler bei der Generierung. Versuche es erneut.</div></div>';
}
} catch (err) {
UI.showToast('Fehler beim Laden der Netzwerkanalyse: ' + err.message, 'error');
}
};
/**
* Netzwerkanalyse-Header rendern.
*/
App._renderNetworkHeader = function(analysis) {
var el;
el = document.getElementById('network-title');
if (el) el.textContent = analysis.name;
el = document.getElementById('network-entity-count');
if (el) el.textContent = analysis.entity_count + ' Entitäten';
el = document.getElementById('network-relation-count');
if (el) el.textContent = analysis.relation_count + ' Beziehungen';
el = document.getElementById('network-incident-list-text');
if (el) el.textContent = (analysis.incident_titles || []).join(', ') || '-';
el = document.getElementById('network-last-generated');
if (el) {
if (analysis.last_generated_at) {
var d = parseUTC(analysis.last_generated_at) || new Date(analysis.last_generated_at);
el.textContent = 'Generiert: ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE }) + ' ' +
d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
} else {
el.textContent = '';
}
}
};
/**
* Filter-Controls in der Netzwerk-Sidebar aufsetzen.
*/
App._setupNetworkFilters = function(graphData) {
// Typ-Filter-Buttons aktivieren
var types = new Set();
(graphData.entities || []).forEach(function(e) { types.add(e.entity_type); });
var filterContainer = document.getElementById('network-type-filter-container');
if (filterContainer) {
var allTypes = ['person', 'organisation', 'location', 'event', 'military'];
var typeLabels = { person: 'Person', organisation: 'Organisation', location: 'Ort', event: 'Ereignis', military: 'Militär' };
filterContainer.innerHTML = allTypes.map(function(t) {
var hasEntities = types.has(t);
return '<button class="network-type-filter active" data-type="' + t + '" onclick="App.toggleNetworkTypeFilter(this)" ' +
(hasEntities ? '' : 'disabled style="opacity:0.3"') + '>' +
'<span class="type-dot"></span><span>' + typeLabels[t] + '</span></button>';
}).join('');
}
// Gewicht-Slider
var slider = document.getElementById('network-weight-slider');
if (slider) {
slider.value = 1;
slider.oninput = function() {
var label = document.getElementById('network-weight-value');
if (label) label.textContent = this.value;
NetworkGraph.filterByWeight(parseInt(this.value));
};
}
// Suche
var searchInput = document.getElementById('network-search');
if (searchInput) {
searchInput.value = '';
var timer = null;
searchInput.oninput = function() {
clearTimeout(timer);
var val = this.value;
timer = setTimeout(function() {
NetworkGraph.search(val);
}, 250);
};
}
};
/**
* Typ-Filter toggle.
*/
App.toggleNetworkTypeFilter = function(btn) {
btn.classList.toggle('active');
var activeTypes = [];
document.querySelectorAll('.network-type-filter.active').forEach(function(b) {
activeTypes.push(b.dataset.type);
});
NetworkGraph.filterByType(new Set(activeTypes));
};
/**
* Progress-Bar anzeigen.
*/
App._showNetworkProgress = function(phase, progress) {
var bar = document.getElementById('network-progress-bar');
if (bar) bar.style.display = 'block';
var steps = ['entity_extraction', 'relationship_extraction', 'correction'];
var stepEls = document.querySelectorAll('.network-progress-step');
var connectorEls = document.querySelectorAll('.network-progress-connector');
var phaseIndex = steps.indexOf(phase);
stepEls.forEach(function(el, i) {
el.classList.remove('active', 'done');
if (i < phaseIndex) el.classList.add('done');
else if (i === phaseIndex) el.classList.add('active');
});
connectorEls.forEach(function(el, i) {
el.classList.remove('done');
if (i < phaseIndex) el.classList.add('done');
});
var fill = document.getElementById('network-progress-fill');
if (fill) {
var pct = ((phaseIndex / steps.length) * 100) + (progress || 0) * (100 / steps.length) / 100;
fill.style.width = Math.min(100, pct) + '%';
}
var label = document.getElementById('network-progress-label');
if (label) {
var labels = { entity_extraction: 'Entitäten werden extrahiert...', relationship_extraction: 'Beziehungen werden analysiert...', correction: 'Korrekturen werden angewendet...' };
label.textContent = labels[phase] || 'Wird verarbeitet...';
}
};
App._hideNetworkProgress = function() {
var bar = document.getElementById('network-progress-bar');
if (bar) bar.style.display = 'none';
};
/**
* Modal: Neue Netzwerkanalyse öffnen.
*/
App.openNetworkModal = async function() {
var list = document.getElementById('network-incident-options');
if (list) list.innerHTML = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade Lagen...</div>';
openModal('modal-network-new');
// Lagen laden
try {
var incidents = await API.listIncidents();
// Sortierung: zuerst Live (adhoc) alphabetisch, dann Analyse (research) alphabetisch
incidents.sort(function(a, b) {
var typeA = (a.type === 'research') ? 1 : 0;
var typeB = (b.type === 'research') ? 1 : 0;
if (typeA !== typeB) return typeA - typeB;
return (a.title || '').localeCompare(b.title || '', 'de');
});
if (list) {
list.innerHTML = incidents.map(function(inc) {
var typeLabel = inc.type === 'research' ? 'Analyse' : 'Live';
return '<label class="network-incident-option">' +
'<input type="checkbox" value="' + inc.id + '" class="network-incident-cb">' +
'<span>' + _escHtml(inc.title) + '</span>' +
'<span class="incident-option-type">' + typeLabel + '</span>' +
'</label>';
}).join('');
}
} catch (e) {
if (list) list.innerHTML = '<div style="padding:12px;color:var(--error);font-size:12px;">Fehler beim Laden der Lagen</div>';
}
// Name-Feld leeren
var nameField = document.getElementById('network-name');
if (nameField) nameField.value = '';
// Suchfeld leeren
var searchField = document.getElementById('network-incident-search');
if (searchField) {
searchField.value = '';
searchField.oninput = function() {
var term = this.value.toLowerCase();
document.querySelectorAll('.network-incident-option').forEach(function(opt) {
var text = opt.textContent.toLowerCase();
opt.style.display = text.includes(term) ? '' : 'none';
});
};
}
};
/**
* Netzwerkanalyse erstellen.
*/
App.submitNetworkAnalysis = async function(e) {
if (e) e.preventDefault();
var name = (document.getElementById('network-name').value || '').trim();
if (!name) {
UI.showToast('Bitte einen Namen eingeben.', 'warning');
return;
}
var incidentIds = [];
document.querySelectorAll('.network-incident-cb:checked').forEach(function(cb) {
incidentIds.push(parseInt(cb.value));
});
if (incidentIds.length === 0) {
UI.showToast('Bitte mindestens eine Lage auswählen.', 'warning');
return;
}
var btn = document.getElementById('network-submit-btn');
if (btn) btn.disabled = true;
try {
var result = await API.createNetworkAnalysis({ name: name, incident_ids: incidentIds });
closeModal('modal-network-new');
await this.loadNetworkAnalyses();
await this.selectNetworkAnalysis(result.id);
UI.showToast('Netzwerkanalyse gestartet.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
};
/**
* Netzwerkanalyse neu generieren.
*/
App.regenerateNetwork = async function() {
if (!this.currentNetworkId) return;
if (!await confirmDialog('Netzwerkanalyse neu generieren? Bestehende Daten werden überschrieben.')) return;
try {
await API.regenerateNetwork(this.currentNetworkId);
this._showNetworkProgress('entity_extraction', 0);
await this.loadNetworkAnalyses();
UI.showToast('Neugenerierung gestartet.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
};
/**
* Netzwerkanalyse löschen.
*/
App.deleteNetworkAnalysis = async function() {
if (!this.currentNetworkId) return;
if (!await confirmDialog('Netzwerkanalyse wirklich löschen? Alle Daten gehen verloren.')) return;
try {
await API.deleteNetworkAnalysis(this.currentNetworkId);
this.currentNetworkId = null;
localStorage.removeItem('selectedNetworkId');
NetworkGraph.destroy();
document.getElementById('network-view').style.display = 'none';
document.getElementById('empty-state').style.display = 'flex';
await this.loadNetworkAnalyses();
UI.showToast('Netzwerkanalyse gelöscht.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
};
/**
* Netzwerkanalyse exportieren.
*/
App.exportNetwork = async function(format) {
if (!this.currentNetworkId) return;
if (format === 'png') {
NetworkGraph.exportPNG();
return;
}
try {
var resp = await API.exportNetworkAnalysis(this.currentNetworkId, format);
if (!resp.ok) throw new Error('Export fehlgeschlagen');
var blob = await resp.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'netzwerk-' + this.currentNetworkId + '.' + format;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
}
};
/**
* WebSocket-Handler für Netzwerk-Events.
*/
App._handleNetworkStatus = function(msg) {
if (msg.analysis_id === this.currentNetworkId) {
this._showNetworkProgress(msg.phase, msg.progress || 0);
}
};
App._handleNetworkComplete = async function(msg) {
this._networkGenerating.delete(msg.analysis_id);
if (msg.analysis_id === this.currentNetworkId) {
this._hideNetworkProgress();
// Graph neu laden
try {
var graphData = await API.getNetworkGraph(msg.analysis_id);
NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData);
var analysis = await API.getNetworkAnalysis(msg.analysis_id);
this._renderNetworkHeader(analysis);
} catch (e) {
console.error('Graph nach Generierung laden fehlgeschlagen:', e);
}
UI.showToast('Netzwerkanalyse fertig: ' + (msg.entity_count || 0) + ' Entitäten, ' + (msg.relation_count || 0) + ' Beziehungen', 'success');
}
await this.loadNetworkAnalyses();
};
App._handleNetworkError = function(msg) {
this._networkGenerating.delete(msg.analysis_id);
if (msg.analysis_id === this.currentNetworkId) {
this._hideNetworkProgress();
var graphArea = document.getElementById('network-graph-area');
if (graphArea) graphArea.innerHTML = '<div class="network-empty-state"><div class="network-empty-state-icon">&#9888;</div><div class="network-empty-state-text">Fehler: ' + _escHtml(msg.error || 'Unbekannter Fehler') + '</div></div>';
}
UI.showToast('Netzwerkanalyse fehlgeschlagen: ' + (msg.error || 'Unbekannter Fehler'), 'error');
this.loadNetworkAnalyses();
};
/**
* Cluster isolieren (nur verbundene Knoten zeigen).
*/
App.isolateNetworkCluster = function() {
if (NetworkGraph._selectedNode) {
NetworkGraph.isolateCluster(NetworkGraph._selectedNode.id);
}
};
/**
* Graph-Ansicht zurücksetzen.
*/
App.resetNetworkView = function() {
NetworkGraph.resetView();
// Typ-Filter zurücksetzen
document.querySelectorAll('.network-type-filter').forEach(function(btn) {
if (!btn.disabled) btn.classList.add('active');
});
var slider = document.getElementById('network-weight-slider');
if (slider) { slider.value = 1; var lbl = document.getElementById('network-weight-value'); if (lbl) lbl.textContent = '1'; }
var search = document.getElementById('network-search');
if (search) search.value = '';
};
// HTML-Escape Hilfsfunktion (falls nicht global verfügbar)
function _escHtml(text) {
if (typeof UI !== 'undefined' && UI.escape) return UI.escape(text);
var d = document.createElement('div');
d.textContent = text || '';
return d.innerHTML;
}

Datei anzeigen

@@ -67,12 +67,12 @@ const Chat = {
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
}
// Tutorial-Hinweis bei jedem Oeffnen aktualisieren (wenn nicht dismissed)
if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
var oldHint = document.getElementById('chat-tutorial-hint');
if (oldHint) oldHint.remove();
this._showTutorialHint();
}
// Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
// if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
// var oldHint = document.getElementById('chat-tutorial-hint');
// if (oldHint) oldHint.remove();
// this._showTutorialHint();
// }
// Focus auf Input
setTimeout(() => {
@@ -137,15 +137,15 @@ const Chat = {
this._showTyping();
this._isLoading = true;
// Tutorial-Keywords abfangen
var lowerText = text.toLowerCase();
if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
this._hideTyping();
this._isLoading = false;
this.close();
if (typeof Tutorial !== 'undefined') Tutorial.start();
return;
}
// Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
// var lowerText = text.toLowerCase();
// if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
// this._hideTyping();
// this._isLoading = false;
// this.close();
// if (typeof Tutorial !== 'undefined') Tutorial.start();
// return;
// }
try {
const body = {

Datei anzeigen

@@ -40,12 +40,32 @@ const UI = {
const activeClass = isActive ? 'active' : '';
const creator = (incident.created_by_username || '').split('@')[0];
// Determine refresh status for sidebar display
let refreshClass = '';
let refreshStatusHtml = '';
if (isRefreshing) {
const state = this._progressState[incident.id];
const step = state ? state.step : 'researching';
const isQueued = (step === 'queued');
if (isQueued) {
refreshClass = ' queued-item';
const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : '';
refreshStatusHtml = '<div class="incident-refresh-status queued-status" id="sidebar-refresh-' + incident.id + '"><span>Warteschlange' + pos + '</span></div>';
} else {
refreshClass = ' refreshing-item';
const label = this._getStepLabel(step);
refreshStatusHtml = '<div class="incident-refresh-status" id="sidebar-refresh-' + incident.id + '"><span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incident.id + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span></div>';
}
}
return `
<div class="incident-item ${activeClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
<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 style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}</div>
${refreshStatusHtml}
</div>
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
@@ -232,200 +252,659 @@ const UI = {
/**
* Fortschrittsanzeige einblenden und Status setzen.
*/
showProgress(status, extra = {}) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
// === Progress State (per-incident) ===
_progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
_progressTimerInterval: null,
const steps = {
queued: { active: 0, label: 'In Warteschlange...' },
researching: { active: 1, label: 'Recherchiert Quellen...' },
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
};
const step = steps[status] || steps.queued;
// Queue-Position anzeigen
let labelText = step.label;
if (status === 'queued' && extra.queue_position > 1) {
labelText = `In Warteschlange (Position ${extra.queue_position})...`;
} else if (extra.detail) {
labelText = extra.detail;
}
// Timer starten beim Übergang von queued zu aktivem Status
if (step.active > 0 && !this._progressStartTime) {
if (extra.started_at) {
// Echte Startzeit vom Server verwenden
const serverStart = parseUTC(extra.started_at);
this._progressStartTime = serverStart ? serverStart.getTime() : Date.now();
} else {
this._progressStartTime = Date.now();
}
this._startProgressTimer();
}
const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking'];
stepIds.forEach((id, i) => {
const el = document.getElementById(id);
if (!el) return;
el.className = 'progress-step';
if (i + 1 < step.active) el.classList.add('done');
else if (i + 1 === step.active) el.classList.add('active');
});
const fill = document.getElementById('progress-fill');
const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100);
if (fill) {
fill.style.width = percent + '%';
}
// ARIA-Werte auf der Progressbar aktualisieren
bar.setAttribute('aria-valuenow', String(percent));
bar.setAttribute('aria-valuetext', labelText);
const label = document.getElementById('progress-label');
if (label) label.textContent = labelText;
// Cancel-Button sichtbar machen
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = '';
_getStepOrder() {
return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
},
/**
* Timer-Intervall starten (1x pro Sekunde).
*/
_startProgressTimer() {
if (this._progressTimer) return;
const timerEl = document.getElementById('progress-timer');
if (!timerEl) return;
_getStepLabel(step) {
const map = {
queued: 'In Warteschlange',
researching: 'Recherchiert...',
deep_researching: 'Tiefenrecherche...',
analyzing: 'Analysiert...',
factchecking: 'Faktencheck...',
cancelling: 'Wird abgebrochen...',
};
return map[step] || step;
},
this._progressTimer = setInterval(() => {
if (!this._progressStartTime) return;
const elapsed = Math.max(0, Math.floor((Date.now() - this._progressStartTime) / 1000));
showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
if (!incidentId) incidentId = App.currentIncidentId;
if (!incidentId) return;
// Init state for this incident
if (!this._progressState[incidentId]) {
this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
}
const state = this._progressState[incidentId];
state.step = status;
if (isFirstRefresh) state.isFirst = true;
// Start timer on first non-queued status
if (status !== 'queued' && !state.startTime) {
if (extra.started_at) {
const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
state.startTime = serverStart ? serverStart.getTime() : Date.now();
} else {
state.startTime = Date.now();
}
}
// Start global timer interval if not running
if (!this._progressTimerInterval) {
this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
}
// Store queue position
if (status === 'queued' && extra.queue_position) {
state._queuePos = extra.queue_position;
}
// Update sidebar status for ALL incidents (not just current)
this._updateSidebarRefreshStatus(incidentId, status, extra);
// Only show popup/mini UI for current incident
if (incidentId !== App.currentIncidentId) return;
if (false) { // popup always shown initially
state.minimized = true;
}
if (state.minimized) {
this._showMiniProgress(status, state);
return;
}
this._showPopupProgress(status, extra, state);
},
_showPopupProgress(status, extra, state) {
const overlay = document.getElementById('progress-overlay');
const popup = document.getElementById('progress-popup');
if (!overlay || !popup) return;
overlay.style.display = 'flex';
this._initClickOutside();
// Blocking (no close) for first refresh
if (state.isFirst) {
overlay.classList.add('blocking');
// Apply blur to incident-view (Header + Tab-Panels gemeinsam).
const blurTarget = document.getElementById('incident-view');
if (blurTarget) {
blurTarget.classList.add('refresh-blurred');
// Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
// (Display-Wechsel, renderSidebar, leere innerHTML) greift
// CSS filter:blur erst beim naechsten Layout-Pass. Im
// naechsten Frame nochmal setzen — idempotent.
requestAnimationFrame(() => {
if (state && state.isFirst) blurTarget.classList.add('refresh-blurred');
});
}
} else {
overlay.classList.remove('blocking');
}
// Minimize button: only for updates (not first)
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
// Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) {
let title;
if (status === 'queued') {
const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
title = 'In Warteschlange' + pos;
} else if (status === 'cancelling') {
title = 'Wird abgebrochen\u2026';
} else if (state.isFirst) {
title = 'Erste Recherche l\u00e4uft';
} else {
title = 'Aktualisierung l\u00e4uft';
}
titleEl.textContent = title;
}
// Multi-pass info
const passEl = document.getElementById('progress-popup-pass');
if (passEl) {
if (extra.research_pass && extra.research_total_passes) {
passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
passEl.style.display = '';
} else {
passEl.style.display = 'none';
}
}
// Update checklist
const stepOrder = this._getStepOrder();
const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
const items = document.querySelectorAll('.progress-check-item');
// Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };
items.forEach(item => {
const step = item.dataset.step;
const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
const icon = item.querySelector('.progress-check-icon');
const detail = item.querySelector('.progress-check-detail');
item.classList.remove('active', 'done', 'error');
if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
item.classList.add('done');
if (icon) icon.innerHTML = '\u2713';
} else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
item.classList.add('active');
if (icon) icon.innerHTML = '<div class="spinner"></div>';
if (detail && extra.detail) detail.textContent = extra.detail;
else if (detail) detail.textContent = '';
} else {
if (icon) icon.innerHTML = '\u25cb';
if (detail) detail.textContent = '';
}
});
// Cancel button
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = '';
cancelBtn.textContent = 'Abbrechen';
cancelBtn.disabled = false;
}
// Hide complete summary
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) summaryEl.style.display = 'none';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
// Lock action buttons during first refresh
this._lockActionsIfFirst(state.isFirst);
},
_lockActionsIfFirst(isFirst) {
const actions = document.querySelector('.incident-header-actions');
if (!actions) return;
if (isFirst) {
actions.classList.add('first-refresh-locked');
} else {
actions.classList.remove('first-refresh-locked');
}
},
_showMiniProgress(status, state) {
const mini = document.getElementById('progress-mini');
if (!mini) return;
mini.style.display = 'flex';
const textEl = document.getElementById('progress-mini-text');
if (textEl) textEl.textContent = this._getStepLabel(status);
// Hide popup
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'none';
},
minimizeProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = true;
state._userOpenedPopup = false;
this._showMiniProgress(state.step, state);
},
openProgressPopup(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = false;
state._userOpenedPopup = true;
this._showPopupProgress(state.step, {}, state);
},
showProgressComplete(data, incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
// Calculate total time
let totalTimeStr = '';
if (state && state.startTime) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
timerEl.textContent = `${mins}:${String(secs).padStart(2, '0')}`;
}, 1000);
totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
}
if (incidentId === App.currentIncidentId) {
// Remove blur
const blurTarget = document.getElementById('incident-view');
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
const overlay = document.getElementById('progress-overlay');
if (overlay) {
overlay.style.display = 'flex';
overlay.classList.remove('blocking');
}
// Mark all steps done
document.querySelectorAll('.progress-check-item').forEach(item => {
item.classList.remove('active', 'error');
item.classList.add('done');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2713';
});
// Show summary
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');
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) {
summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
+ (totalTimeStr ? '<span class="total-time">Gesamtzeit: ' + totalTimeStr + '</span>' : '');
summaryEl.style.display = 'block';
}
// Update title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = 'Abgeschlossen';
// Hide cancel, show minimize
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = '';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Remove sidebar refresh status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state after delay
setTimeout(() => {
this.hideProgress(incidentId);
}, 5000);
},
/**
* Abschluss-Animation: Grüner Balken mit Summary-Text.
*/
showProgressComplete(data) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
if (!incidentId) incidentId = App.currentIncidentId;
if (incidentId !== App.currentIncidentId) return;
// Timer stoppen
this._stopProgressTimer();
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'flex';
// Alle Steps auf done
['step-researching', 'step-analyzing', 'step-factchecking'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.className = 'progress-step done'; }
});
// Fill auf 100%
const fill = document.getElementById('progress-fill');
if (fill) fill.style.width = '100%';
// Complete-Klasse
bar.classList.remove('progress-bar--error');
bar.classList.add('progress-bar--complete');
// Label mit Summary
const parts = [];
if (data.new_articles > 0) {
parts.push(`${data.new_articles} neue Artikel`);
// Mark current step as error
const state = this._progressState[incidentId];
if (state) {
const items = document.querySelectorAll('.progress-check-item.active');
items.forEach(item => {
item.classList.remove('active');
item.classList.add('error');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2717';
});
}
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) {
titleEl.textContent = willRetry
? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
: 'Fehlgeschlagen: ' + errorMsg;
}
if (data.confirmed_count > 0) {
parts.push(`${data.confirmed_count} Fakten bestätigt`);
}
if (data.contradicted_count > 0) {
parts.push(`${data.contradicted_count} widerlegt`);
}
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const label = document.getElementById('progress-label');
if (label) label.textContent = `Abgeschlossen: ${summaryText}`;
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
bar.setAttribute('aria-valuenow', '100');
bar.setAttribute('aria-valuetext', 'Abgeschlossen');
},
/**
* Fehler-Zustand: Roter Balken mit Fehlermeldung.
*/
showProgressError(errorMsg, willRetry = false, delay = 0) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
// Timer stoppen
this._stopProgressTimer();
// Error-Klasse
bar.classList.remove('progress-bar--complete');
bar.classList.add('progress-bar--error');
const label = document.getElementById('progress-label');
if (label) {
label.textContent = willRetry
? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...`
: `Fehlgeschlagen: ${errorMsg}`;
}
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
// Bei finalem Fehler nach 6s ausblenden
if (!willRetry) {
setTimeout(() => this.hideProgress(), 6000);
this._removeSidebarRefreshStatus(incidentId);
setTimeout(() => this.hideProgress(incidentId), 6000);
}
},
/**
* Timer-Intervall stoppen und zurücksetzen.
*/
_stopProgressTimer() {
if (this._progressTimer) {
clearInterval(this._progressTimer);
this._progressTimer = null;
hideProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
// Remove blur
const blurTarget = document.getElementById('incident-view');
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
if (incidentId === App.currentIncidentId) {
const overlay = document.getElementById('progress-overlay');
if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Unlock action buttons
this._lockActionsIfFirst(false);
// Remove sidebar status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state
delete this._progressState[incidentId];
// Stop timer if no more active refreshes
if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
clearInterval(this._progressTimerInterval);
this._progressTimerInterval = null;
}
this._progressStartTime = null;
const timerEl = document.getElementById('progress-timer');
if (timerEl) timerEl.textContent = '';
},
/**
* Fortschrittsanzeige ausblenden.
*/
hideProgress() {
const bar = document.getElementById('progress-bar');
if (bar) {
bar.style.display = 'none';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
_tickProgressTimers() {
for (const [id, state] of Object.entries(this._progressState)) {
if (!state.startTime) continue;
const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins + ':' + String(secs).padStart(2, '0');
if (parseInt(id) === App.currentIncidentId) {
// Update popup timer
const timerEl = document.getElementById('progress-popup-timer');
if (timerEl) timerEl.textContent = timeStr;
// Update mini timer
const miniTimer = document.getElementById('progress-mini-timer');
if (miniTimer) miniTimer.textContent = timeStr;
}
// Update sidebar timer for this incident
const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
if (sidebarTimer) sidebarTimer.textContent = timeStr;
}
this._stopProgressTimer();
},
// === Sidebar Refresh Status ===
_updateSidebarRefreshStatus(incidentId, status, extra) {
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (!item) return;
const isQueued = (status === 'queued');
// Add appropriate class
item.classList.remove('refreshing-item', 'queued-item');
item.classList.add(isQueued ? 'queued-item' : 'refreshing-item');
// Add or update status text below meta
let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (!statusEl) {
const textCol = item.querySelector('div[style*="flex:1"]');
if (!textCol) return;
statusEl = document.createElement('div');
statusEl.id = 'sidebar-refresh-' + incidentId;
textCol.appendChild(statusEl);
}
if (isQueued) {
const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || '');
// Store queue position in state for renderIncidentItem
const pState = this._progressState[incidentId];
if (pState && pos) pState._queuePos = pos;
statusEl.className = 'incident-refresh-status queued-status';
statusEl.innerHTML = '<span>Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '</span>';
} else {
statusEl.className = 'incident-refresh-status';
const label = this._getStepLabel(status);
statusEl.innerHTML = '<span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incidentId + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span>';
}
},
_removeSidebarRefreshStatus(incidentId) {
const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (statusEl) statusEl.remove();
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (item) item.classList.remove('refreshing-item', 'queued-item');
},
_reindexQueuePositions() {
// Collect all queued incidents and renumber sequentially
const queued = [];
for (const [id, state] of Object.entries(this._progressState)) {
if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 });
}
queued.sort((a, b) => a.pos - b.pos);
queued.forEach((item, idx) => {
const newPos = idx + 1;
const state = this._progressState[item.id];
if (state) state._queuePos = newPos;
const statusEl = document.getElementById('sidebar-refresh-' + item.id);
if (statusEl) statusEl.innerHTML = '<span>Warteschlange (#' + newPos + ')</span>';
});
},
// === Click-outside to auto-minimize popup ===
_initClickOutside() {
if (this._clickOutsideInit) return;
this._clickOutsideInit = true;
document.addEventListener('click', (e) => {
const overlay = document.getElementById('progress-overlay');
if (!overlay || overlay.style.display === 'none') return;
const popup = document.getElementById('progress-popup');
if (!popup) return;
// Ignore clicks inside the popup itself
if (popup.contains(e.target)) return;
// Ignore clicks on the mini bar
const mini = document.getElementById('progress-mini');
if (mini && mini.contains(e.target)) return;
// Don't minimize during first refresh (blocking)
const currentId = App.currentIncidentId;
const state = this._progressState[currentId];
if (state && state.isFirst) return;
// Auto-minimize
if (state && !state.minimized) {
this.minimizeProgress(currentId);
}
});
},
/**
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/
/**
* Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
* Returns: { zusammenfassung: string|null, remaining: string }
*/
extractZusammenfassung(summary) {
if (!summary) return { zusammenfassung: null, remaining: summary };
const pattern = /## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n(.*?)(?=\n## |$)/s;
const match = summary.match(pattern);
if (!match) return { zusammenfassung: null, remaining: summary };
const zusammenfassung = match[1].trim();
const remaining = summary.substring(0, match.index) + summary.substring(match.index + match[0].length);
return { zusammenfassung, remaining: remaining.trim() };
},
/**
* Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER
* JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet.
*/
_parseSources(input) {
if (!input) return [];
if (Array.isArray(input)) return input;
try {
const parsed = JSON.parse(input);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
},
/**
* Rendert die Zusammenfassung als HTML (Bullet Points).
*/
renderZusammenfassung(text, sourcesJson) {
if (!text) return '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung.</span>';
const sources = this._parseSources(sourcesJson);
// Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern
const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- "));
const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text;
let html = this.escape(bulletText);
// Bullet points
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul style="margin:4px 0 4px 18px;line-height:1.7;">$&</ul>');
// Zeilenumbrueche
html = html.replace(/\n(?!<)/g, '<br>');
html = html.replace(/(<br>){2,}/g, '<br>');
// Inline-Zitate als klickbare Links
if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
if ((!src || !src.url) && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
if (baseSrc && baseSrc.url) src = baseSrc;
}
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
}
return match;
});
}
return html;
},
/**
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
* Legacy: Inline-[N]-Citations werden als Fallback ebenfalls erkannt.
*/
renderLatestDevelopments(text, sourcesJson) {
if (!text) return '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst.</span>';
const sources = this._parseSources(sourcesJson);
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("[")));
if (bulletLines.length === 0) {
return this.renderZusammenfassung(text, sourcesJson);
}
const bulletRe = /^(?:-\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$/;
const citationRe = /\[(\d+[a-z]?)\]/g;
const trailingNamesRe = /\s*\{([^{}]+)\}\s*\.?\s*$/;
const lookupByNum = (num) => {
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
if (!src && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
src = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
}
return src || null;
};
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
const lookupByName = (name) => {
const n = normalize(name);
if (!n) return null;
let src = sources.find(s => normalize(s.name) === n);
if (src) return src;
src = sources.find(s => {
const sn = normalize(s.name);
return sn.includes(n) || n.includes(sn);
});
return src || null;
};
const buildPill = (src, fallbackName) => {
const displayName = src ? (src.name || fallbackName) : fallbackName;
const url = (src && src.url) || '';
const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i);
const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName;
const esc = this.escape(label);
const titleEsc = this.escape(displayName);
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="dev-source-pill" title="${titleEsc}">${esc}</a>`;
}
return `<span class="dev-source-pill" title="${titleEsc}">${esc}</span>`;
};
const cards = bulletLines.map(line => {
const m = bulletRe.exec(line);
if (!m) {
const body = this.escape(line.replace(/^-\s*/, ''));
return `<div class="dev-bullet"><div class="dev-body">${body}</div></div>`;
}
const day = m[1].padStart(2, '0');
const month = m[2].padStart(2, '0');
const date = `${day}.${month}.`;
const time = m[3];
let rawBody = m[4];
let pillsHtml = '';
// Primär: {Name1|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende
const trailing = trailingNamesRe.exec(rawBody);
if (trailing) {
rawBody = rawBody.replace(trailingNamesRe, '').trim();
const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean);
const seen = new Set();
pillsHtml = items.map(item => {
// Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name
const pipeIdx = item.indexOf('|');
const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim();
const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : '';
if (!itemName) return '';
const key = normalize(itemName);
if (seen.has(key)) return '';
seen.add(key);
if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return '';
// Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich
if (itemUrl) {
return buildPill({ name: itemName, url: itemUrl }, itemName);
}
// Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json
const src = lookupByName(itemName);
return buildPill(src, itemName);
}).filter(Boolean).join('');
}
// Fallback: Inline-[N]-Citations (Legacy-Recherche-Format)
if (!pillsHtml) {
const nums = [];
let cm;
while ((cm = citationRe.exec(rawBody)) !== null) {
if (!nums.includes(cm[1])) nums.push(cm[1]);
}
citationRe.lastIndex = 0;
if (nums.length > 0) {
rawBody = rawBody.replace(citationRe, '').replace(/\s+/g, ' ').trim();
pillsHtml = nums.map(num => {
const src = lookupByNum(num);
return src ? buildPill(src, src.name || `Quelle ${num}`) : '';
}).filter(Boolean).join('');
}
}
const cleanBody = this.escape(rawBody.trim());
const sourcesHtml = pillsHtml ? `<span class="dev-sources">${pillsHtml}</span>` : '<span class="dev-sources"></span>';
const timeHtml = `<span class="dev-time" title="${this.escape(date + ' ' + time)}">${this.escape(time)} \u00b7 ${this.escape(date)}</span>`;
return `<div class="dev-bullet"><div class="dev-bullet-head">${sourcesHtml}${timeHtml}</div><div class="dev-body">${cleanBody}</div></div>`;
});
return `<div class="dev-list">${cards.join('')}</div>`;
},
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
const sources = this._parseSources(sourcesJson);
// Markdown-Rendering
let html = this.escape(summary);
@@ -444,10 +923,34 @@ const UI = {
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
html = html.replace(/(<br>){2,}/g, '<br>');
// Inline-Zitate [1], [2] etc. als klickbare Links rendern
// Markdown-Tabellen rendern
html = html.replace(/(?:^|<br>)((?:\|.+\|(?:<br>|$))+)/g, function(match, tableBlock) {
var rows = tableBlock.split('<br>').filter(function(r) { return r.trim().length > 0; });
if (rows.length < 2) return match;
var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); };
if (!isSep(rows[1])) return match;
var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); };
var headerCells = parseRow(rows[0]);
var thead = '<thead><tr>' + headerCells.map(function(c) { return '<th>' + c + '</th>'; }).join('') + '</tr></thead>';
var tbody = '<tbody>' + rows.slice(2).map(function(r) {
if (isSep(r)) return '';
var cells = parseRow(r);
return '<tr>' + cells.map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody>';
return '<div class="summary-table-wrap"><table class="summary-table">' + thead + tbody + '</table></div>';
});
// Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
const src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
// Exakte Suche (auch mit Buchstaben-Suffix)
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
// Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
if ((!src || !src.url) && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
if (baseSrc && baseSrc.url) src = baseSrc;
}
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
}
@@ -461,6 +964,38 @@ const UI = {
/**
* Quellenübersicht für eine Lage rendern.
*/
/**
* Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage,
* unabhaengig von Paginierung im Frontend).
* data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]}
*/
renderSourceOverviewFromSummary(data) {
if (!data || !data.sources || data.sources.length === 0) return '';
const langChips = (data.language_counts || [])
.map(l => `<span class="source-lang-chip">${(l.language || 'de').toUpperCase()} <strong>${l.cnt}</strong></span>`)
.join('');
let html = `<div class="source-overview-header">`;
html += `<span class="source-overview-stat">${data.total} Artikel aus ${data.sources.length} Quellen</span>`;
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
html += '<div class="source-overview-grid">';
data.sources.forEach(s => {
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
const sourceName = this.escape(s.source || 'Unbekannt');
html += `<div class="source-overview-item" data-source="${sourceName}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
<span class="source-overview-name">${sourceName}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${s.article_count}</span>
</div>`;
});
html += '</div>';
return html;
},
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';
@@ -523,6 +1058,7 @@ const UI = {
'international': 'Intl.',
'regional': 'Regional',
'boulevard': 'Boulevard',
'telegram': 'Telegram',
'sonstige': 'Sonstige',
},

Datei anzeigen

@@ -1,278 +1,75 @@
/**
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
* LayoutManager: Tab-Navigation fuer das Monitor-Dashboard.
* Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage.
*/
const LayoutManager = {
_grid: null,
_storageKey: 'osint_layout',
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'pipeline', 'quellen'],
_currentIncidentId: null,
_initialized: false,
_saveTimeout: null,
_hiddenTiles: {},
DEFAULT_LAYOUT: [
{ id: 'lagebild', x: 0, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
{ id: 'faktencheck', x: 6, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
{ id: 'quellen', x: 0, y: 4, w: 12, h: 2, minW: 6, minH: 2 },
{ id: 'timeline', x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 4 },
{ id: 'karte', x: 0, y: 9, w: 12, h: 8, minW: 6, minH: 3 },
],
TILE_MAP: {
lagebild: '.incident-analysis-summary',
faktencheck: '.incident-analysis-factcheck',
quellen: '.source-overview-card',
timeline: '.timeline-card',
karte: '.map-card',
},
init() {
if (this._initialized) return;
const nav = document.getElementById('tab-nav');
if (!nav) return;
const container = document.querySelector('.grid-stack');
if (!container) return;
this._grid = GridStack.init({
column: 12,
cellHeight: 80,
margin: 12,
animate: true,
handle: '.card-header',
float: false,
disableOneColumnMode: true,
}, container);
const saved = this._load();
if (saved) {
this._applyLayout(saved);
}
this._grid.on('change', () => {
this._debouncedSave();
// Leaflet-Map bei Resize invalidieren
if (typeof UI !== 'undefined') UI.invalidateMap();
});
const toolbar = document.getElementById('layout-toolbar');
if (toolbar) toolbar.style.display = 'flex';
this._syncToggles();
this._initialized = true;
},
_applyLayout(layout) {
if (!this._grid) return;
this._hiddenTiles = {};
layout.forEach(item => {
const el = this._grid.engine.nodes.find(n => n.el && n.el.getAttribute('gs-id') === item.id);
if (!el) return;
if (item.visible === false) {
this._hiddenTiles[item.id] = item;
this._grid.removeWidget(el.el, true, false);
} else {
this._grid.update(el.el, { x: item.x, y: item.y, w: item.w, h: item.h });
}
});
this._syncToggles();
},
save() {
if (!this._grid) return;
const items = [];
this._grid.engine.nodes.forEach(node => {
const id = node.el ? node.el.getAttribute('gs-id') : null;
if (!id) return;
items.push({
id, x: node.x, y: node.y, w: node.w, h: node.h, visible: true,
nav.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-tab');
if (tab) this.switchTab(tab);
});
});
Object.keys(this._hiddenTiles).forEach(id => {
items.push({ ...this._hiddenTiles[id], visible: false });
nav.style.display = '';
this._initialized = true;
},
switchTab(tabId, save = true) {
if (!this.TAB_ORDER.includes(tabId)) tabId = 'zusammenfassung';
document.querySelectorAll('#tab-nav .tab-btn').forEach(b => {
b.classList.toggle('active', b.getAttribute('data-tab') === tabId);
});
document.querySelectorAll('.tab-panel').forEach(p => {
p.classList.toggle('active', p.id === 'panel-' + tabId);
});
// Leaflet-Karte: invalidateSize nach Panel-Wechsel, damit Tiles korrekt rendern
if (tabId === 'karte' && typeof UI !== 'undefined' && UI._map) {
setTimeout(() => { try { UI._map.invalidateSize(); } catch (e) { /* ignore */ } }, 50);
}
if (save && this._currentIncidentId != null) {
try {
localStorage.setItem('osint_tab_' + this._currentIncidentId, tabId);
} catch (e) { /* quota */ }
}
},
restoreTabFor(incidentId) {
this._currentIncidentId = incidentId;
let target = 'zusammenfassung';
try {
localStorage.setItem(this._storageKey, JSON.stringify(items));
} catch (e) { /* quota */ }
const saved = localStorage.getItem('osint_tab_' + incidentId);
if (saved && this.TAB_ORDER.includes(saved)) target = saved;
} catch (e) { /* ignore */ }
this.switchTab(target, false);
},
_debouncedSave() {
clearTimeout(this._saveTimeout);
this._saveTimeout = setTimeout(() => this.save(), 300);
/** Tab-Labels je Incident-Typ anpassen (adhoc vs. research). */
applyTypeLabels(incidentType) {
const isResearch = incidentType === 'research';
const zf = document.querySelector('#tab-nav .tab-btn[data-tab="zusammenfassung"]');
const lb = document.querySelector('#tab-nav .tab-btn[data-tab="lagebild"]');
if (zf) zf.textContent = isResearch ? 'Zusammenfassung' : 'Neueste Entwicklungen';
if (lb) lb.textContent = isResearch ? 'Recherchebericht' : 'Lagebild';
},
_load() {
try {
const raw = localStorage.getItem(this._storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch (e) {
return null;
}
},
toggleTile(tileId) {
if (!this._grid) return;
const selector = this.TILE_MAP[tileId];
if (!selector) return;
if (this._hiddenTiles[tileId]) {
// Kachel einblenden
const cfg = this._hiddenTiles[tileId];
delete this._hiddenTiles[tileId];
const cardEl = document.querySelector(selector);
if (!cardEl) return;
// Wrapper erstellen
const wrapper = document.createElement('div');
wrapper.className = 'grid-stack-item';
wrapper.setAttribute('gs-id', tileId);
wrapper.setAttribute('gs-x', cfg.x);
wrapper.setAttribute('gs-y', cfg.y);
wrapper.setAttribute('gs-w', cfg.w);
wrapper.setAttribute('gs-h', cfg.h);
wrapper.setAttribute('gs-min-w', cfg.minW || '');
wrapper.setAttribute('gs-min-h', cfg.minH || '');
const content = document.createElement('div');
content.className = 'grid-stack-item-content';
content.appendChild(cardEl);
wrapper.appendChild(content);
this._grid.addWidget(wrapper);
} else {
// Kachel ausblenden
const node = this._grid.engine.nodes.find(
n => n.el && n.el.getAttribute('gs-id') === tileId
);
if (!node) return;
const defaults = this.DEFAULT_LAYOUT.find(d => d.id === tileId);
this._hiddenTiles[tileId] = {
id: tileId,
x: node.x, y: node.y, w: node.w, h: node.h,
minW: defaults ? defaults.minW : 4,
minH: defaults ? defaults.minH : 2,
visible: false,
};
// Card aus dem Widget retten bevor es entfernt wird
const cardEl = node.el.querySelector(selector);
if (cardEl) {
// Temporär im incident-view parken (unsichtbar)
const parking = document.getElementById('tile-parking');
if (parking) parking.appendChild(cardEl);
}
this._grid.removeWidget(node.el, true, false);
}
this._syncToggles();
this.save();
},
_syncToggles() {
document.querySelectorAll('.layout-toggle-btn').forEach(btn => {
const tileId = btn.getAttribute('data-tile');
const isHidden = !!this._hiddenTiles[tileId];
btn.classList.toggle('active', !isHidden);
btn.setAttribute('aria-pressed', String(!isHidden));
});
},
reset() {
localStorage.removeItem(this._storageKey);
// Cards einsammeln BEVOR der Grid zerstört wird (aus Grid + Parking)
const cards = {};
Object.entries(this.TILE_MAP).forEach(([id, selector]) => {
const card = document.querySelector(selector);
if (card) cards[id] = card;
});
this._hiddenTiles = {};
if (this._grid) {
this._grid.destroy(false);
this._grid = null;
}
this._initialized = false;
const gridEl = document.querySelector('.grid-stack');
if (!gridEl) return;
// Grid leeren (Cards sind bereits in cards-Map gesichert)
gridEl.innerHTML = '';
// Cards in Default-Layout neu aufbauen
this.DEFAULT_LAYOUT.forEach(cfg => {
const cardEl = cards[cfg.id];
if (!cardEl) return;
const wrapper = document.createElement('div');
wrapper.className = 'grid-stack-item';
wrapper.setAttribute('gs-id', cfg.id);
wrapper.setAttribute('gs-x', cfg.x);
wrapper.setAttribute('gs-y', cfg.y);
wrapper.setAttribute('gs-w', cfg.w);
wrapper.setAttribute('gs-h', cfg.h);
wrapper.setAttribute('gs-min-w', cfg.minW);
wrapper.setAttribute('gs-min-h', cfg.minH);
const content = document.createElement('div');
content.className = 'grid-stack-item-content';
content.appendChild(cardEl);
wrapper.appendChild(content);
gridEl.appendChild(wrapper);
});
this.init();
},
resizeTileToContent(tileId) {
if (!this._grid) return;
const node = this._grid.engine.nodes.find(
n => n.el && n.el.getAttribute('gs-id') === tileId
);
if (!node || !node.el) return;
const wrapper = node.el.querySelector('.grid-stack-item-content');
if (!wrapper) return;
const card = wrapper.firstElementChild;
if (!card) return;
const cellH = this._grid.opts.cellHeight || 80;
const margin = this._grid.opts.margin || 12;
// Temporär alle height-Constraints aufheben
node.el.classList.add('gs-measuring');
const naturalHeight = card.scrollHeight;
node.el.classList.remove('gs-measuring');
// In Grid-Units umrechnen (aufrunden + 1 Puffer)
const neededH = Math.ceil(naturalHeight / (cellH + margin)) + 1;
const minH = node.minH || 2;
const finalH = Math.max(neededH, minH);
this._grid.update(node.el, { h: finalH });
this._debouncedSave();
},
destroy() {
if (this._grid) {
this._grid.destroy(false);
this._grid = null;
}
this._initialized = false;
this._hiddenTiles = {};
},
// Legacy-API-Stubs: falls alte Aufrufe im Code liegen, stumm schlucken statt crashen.
toggleTile() { /* legacy no-op */ },
reset() { /* legacy no-op */ },
save() { /* legacy no-op */ },
resizeTileToContent() { /* legacy no-op */ },
destroy() { /* legacy no-op */ },
};
document.addEventListener('DOMContentLoaded', () => LayoutManager.init());

Datei anzeigen

@@ -1,831 +0,0 @@
/**
* AegisSight OSINT Monitor - Network Graph Visualization
*
* Force-directed graph powered by d3.js v7.
* Expects d3 to be loaded globally from CDN before this script runs.
*
* Usage:
* NetworkGraph.init('network-graph-area', data);
* NetworkGraph.filterByType(new Set(['person', 'organisation']));
* NetworkGraph.search('Russland');
* NetworkGraph.destroy();
*/
/* global d3 */
const NetworkGraph = {
// ---- internal state -------------------------------------------------------
_svg: null,
_simulation: null,
_data: null, // raw data as received
_filtered: null, // currently visible subset
_container: null, // <g> inside SVG that receives zoom transforms
_zoom: null,
_selectedNode: null,
_tooltip: null,
_filters: {
types: new Set(), // empty = all visible
minWeight: 1,
searchTerm: '',
},
_colorMap: {
node: {
person: '#60A5FA',
organisation: '#C084FC',
location: '#34D399',
event: '#FBBF24',
military: '#F87171',
},
edge: {
alliance: '#34D399',
conflict: '#EF4444',
diplomacy: '#FBBF24',
economic: '#60A5FA',
legal: '#C084FC',
neutral: '#6B7280',
},
},
// ---- public API -----------------------------------------------------------
/**
* Initialise the graph inside the given container element.
* @param {string} containerId – DOM id of the wrapper element
* @param {object} data – { entities: [], relations: [] }
*/
init(containerId, data) {
this.destroy();
const wrapper = document.getElementById(containerId);
if (!wrapper) {
console.error('[NetworkGraph] Container #' + containerId + ' not found.');
return;
}
this._data = this._prepareData(data);
this._filters = { types: new Set(), minWeight: 1, searchTerm: '' };
this._selectedNode = null;
const rect = wrapper.getBoundingClientRect();
const width = rect.width || 960;
const height = rect.height || 640;
// SVG
this._svg = d3.select(wrapper)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', [0, 0, width, height].join(' '))
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('background', 'transparent');
// Defs: arrow markers per category
this._createMarkers();
// Defs: glow filter for top-connected nodes
this._createGlowFilter();
// Zoom container
this._container = this._svg.append('g').attr('class', 'ng-zoom-layer');
// Zoom behaviour
this._zoom = d3.zoom()
.scaleExtent([0.1, 8])
.on('zoom', (event) => {
this._container.attr('transform', event.transform);
});
this._svg.call(this._zoom);
// Double-click resets zoom
this._svg.on('dblclick.zoom', null);
this._svg.on('dblclick', () => this.resetView());
// Tooltip
this._tooltip = d3.select(wrapper)
.append('div')
.attr('class', 'ng-tooltip')
.style('position', 'absolute')
.style('pointer-events', 'none')
.style('background', 'rgba(15,23,42,0.92)')
.style('color', '#e2e8f0')
.style('border', '1px solid #334155')
.style('border-radius', '6px')
.style('padding', '6px 10px')
.style('font-size', '12px')
.style('max-width', '260px')
.style('z-index', '1000')
.style('display', 'none');
// Simulation
this._simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(d => {
// Inverse weight: higher weight -> closer
return Math.max(40, 200 - d.weight * 25);
}))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(d => d._radius + 6))
.alphaDecay(0.02);
this.render();
},
/**
* Tear down the graph completely.
*/
destroy() {
if (this._simulation) {
this._simulation.stop();
this._simulation = null;
}
if (this._svg) {
this._svg.remove();
this._svg = null;
}
if (this._tooltip) {
this._tooltip.remove();
this._tooltip = null;
}
this._container = null;
this._data = null;
this._filtered = null;
this._selectedNode = null;
},
/**
* Full re-render based on current filters.
*/
render() {
if (!this._data || !this._container) return;
this._applyFilters();
const nodes = this._filtered.entities;
const links = this._filtered.relations;
// Clear previous drawing
this._container.selectAll('*').remove();
// Determine top-5 most connected node IDs
const connectionCounts = {};
this._data.relations.forEach(r => {
connectionCounts[r.source_entity_id] = (connectionCounts[r.source_entity_id] || 0) + 1;
connectionCounts[r.target_entity_id] = (connectionCounts[r.target_entity_id] || 0) + 1;
});
const top5Ids = new Set(
Object.entries(connectionCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(e => e[0])
);
// Radius scale (sqrt of connection count)
const maxConn = Math.max(1, ...Object.values(connectionCounts));
const rScale = d3.scaleSqrt().domain([0, maxConn]).range([8, 40]);
nodes.forEach(n => {
n._connections = connectionCounts[n.id] || 0;
n._radius = rScale(n._connections);
n._isTop5 = top5Ids.has(n.id);
});
// ---- edges ------------------------------------------------------------
const linkGroup = this._container.append('g').attr('class', 'ng-links');
const linkSel = linkGroup.selectAll('line')
.data(links, d => d.id)
.join('line')
.attr('stroke', d => this._colorMap.edge[d.category] || this._colorMap.edge.neutral)
.attr('stroke-width', d => Math.max(1, d.weight * 0.8))
.attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14))
.attr('marker-end', d => 'url(#ng-arrow-' + (d.category || 'neutral') + ')')
.style('cursor', 'pointer')
.on('mouseover', (event, d) => {
const lines = [];
if (d.label) lines.push('<strong>' + this._esc(d.label) + '</strong>');
if (d.description) lines.push(this._esc(d.description));
lines.push('Kategorie: ' + this._esc(d.category) + ' | Gewicht: ' + d.weight);
this._showTooltip(event, lines.join('<br>'));
})
.on('mousemove', (event) => this._moveTooltip(event))
.on('mouseout', () => this._hideTooltip());
// ---- nodes ------------------------------------------------------------
const nodeGroup = this._container.append('g').attr('class', 'ng-nodes');
const nodeSel = nodeGroup.selectAll('g')
.data(nodes, d => d.id)
.join('g')
.attr('class', 'ng-node')
.style('cursor', 'pointer')
.call(this._drag(this._simulation))
.on('mouseover', (event, d) => {
this._showTooltip(event, '<strong>' + this._esc(d.name) + '</strong><br>' +
this._esc(d.entity_type) + ' | Verbindungen: ' + d._connections);
})
.on('mousemove', (event) => this._moveTooltip(event))
.on('mouseout', () => this._hideTooltip())
.on('click', (event, d) => {
event.stopPropagation();
this._onNodeClick(d, linkSel, nodeSel);
});
// Circle
nodeSel.append('circle')
.attr('r', d => d._radius)
.attr('fill', d => this._colorMap.node[d.entity_type] || '#94A3B8')
.attr('stroke', '#0f172a')
.attr('stroke-width', 1.5)
.attr('filter', d => d._isTop5 ? 'url(#ng-glow)' : null);
// Label
nodeSel.append('text')
.text(d => d.name.length > 15 ? d.name.slice(0, 14) + '\u2026' : d.name)
.attr('dy', d => d._radius + 14)
.attr('text-anchor', 'middle')
.attr('fill', '#cbd5e1')
.attr('font-size', '10px')
.attr('pointer-events', 'none');
// ---- simulation -------------------------------------------------------
// Build link data with object references (d3 expects id strings or objects)
const simNodes = nodes;
const simLinks = links.map(l => ({
...l,
source: typeof l.source === 'object' ? l.source.id : l.source_entity_id,
target: typeof l.target === 'object' ? l.target.id : l.target_entity_id,
}));
this._simulation.nodes(simNodes);
this._simulation.force('link').links(simLinks);
this._simulation.force('collide').radius(d => d._radius + 6);
this._simulation.alpha(1).restart();
this._simulation.on('tick', () => {
linkSel
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => {
// Shorten line so arrow doesn't overlap circle
const target = d.target;
const dx = target.x - d.source.x;
const dy = target.y - d.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return target.x - (dx / dist) * (target._radius + 4);
})
.attr('y2', d => {
const target = d.target;
const dx = target.x - d.source.x;
const dy = target.y - d.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return target.y - (dy / dist) * (target._radius + 4);
});
nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
});
// Click on background to deselect
this._svg.on('click', () => {
this._selectedNode = null;
nodeSel.select('circle').attr('stroke', '#0f172a').attr('stroke-width', 1.5);
linkSel.attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14));
this._clearDetailPanel();
});
// Apply search highlight if active
if (this._filters.searchTerm) {
this._applySearchHighlight(nodeSel);
}
},
// ---- filtering ------------------------------------------------------------
/**
* Compute the visible subset from raw data + current filters.
*/
_applyFilters() {
let entities = this._data.entities.slice();
let relations = this._data.relations.slice();
// Type filter
if (this._filters.types.size > 0) {
const allowed = this._filters.types;
entities = entities.filter(e => allowed.has(e.entity_type));
const visibleIds = new Set(entities.map(e => e.id));
relations = relations.filter(r =>
visibleIds.has(r.source_entity_id) && visibleIds.has(r.target_entity_id)
);
}
// Weight filter
if (this._filters.minWeight > 1) {
relations = relations.filter(r => r.weight >= this._filters.minWeight);
}
// Cluster isolation
if (this._filters._isolateId) {
const centerId = this._filters._isolateId;
const connectedIds = new Set([centerId]);
relations.forEach(r => {
if (r.source_entity_id === centerId) connectedIds.add(r.target_entity_id);
if (r.target_entity_id === centerId) connectedIds.add(r.source_entity_id);
});
entities = entities.filter(e => connectedIds.has(e.id));
relations = relations.filter(r =>
connectedIds.has(r.source_entity_id) && connectedIds.has(r.target_entity_id)
);
}
this._filtered = { entities, relations };
},
/**
* Populate the detail panel (#network-detail-panel) with entity info.
* @param {object} entity
*/
_updateDetailPanel(entity) {
const panel = document.getElementById('network-detail-panel');
if (!panel) return;
const typeColor = this._colorMap.node[entity.entity_type] || '#94A3B8';
// Connected relations
const connected = this._data.relations.filter(
r => r.source_entity_id === entity.id || r.target_entity_id === entity.id
);
// Group by category
const grouped = {};
connected.forEach(r => {
const cat = r.category || 'neutral';
if (!grouped[cat]) grouped[cat] = [];
// Determine the "other" entity
const otherId = r.source_entity_id === entity.id ? r.target_entity_id : r.source_entity_id;
const other = this._data.entities.find(e => e.id === otherId);
grouped[cat].push({ relation: r, other });
});
let html = '';
// Header
html += '<div style="margin-bottom:12px;">';
html += '<h3 style="margin:0 0 6px 0;color:#f1f5f9;font-size:16px;">' + this._esc(entity.name) + '</h3>';
html += '<span style="display:inline-block;background:' + typeColor + ';color:#0f172a;' +
'padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;text-transform:uppercase;">' +
this._esc(entity.entity_type) + '</span>';
if (entity.corrected_by_opus) {
html += ' <span style="display:inline-block;background:#FBBF24;color:#0f172a;' +
'padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">Corrected by Opus</span>';
}
html += '</div>';
// Description
if (entity.description) {
html += '<p style="color:#94a3b8;font-size:13px;margin:0 0 10px 0;">' +
this._esc(entity.description) + '</p>';
}
// Aliases
if (entity.aliases && entity.aliases.length > 0) {
html += '<div style="margin-bottom:10px;">';
html += '<strong style="color:#cbd5e1;font-size:12px;">Aliase:</strong><br>';
entity.aliases.forEach(a => {
html += '<span style="display:inline-block;background:#1e293b;color:#94a3b8;' +
'padding:1px 6px;border-radius:3px;font-size:11px;margin:2px 4px 2px 0;">' +
this._esc(a) + '</span>';
});
html += '</div>';
}
// Mention count
html += '<div style="margin-bottom:10px;color:#94a3b8;font-size:12px;">';
html += 'Erw\u00e4hnungen: <strong style="color:#f1f5f9;">' +
(entity.mention_count || 0) + '</strong>';
html += '</div>';
// Relations grouped by category
const categoryLabels = {
alliance: 'Allianz', conflict: 'Konflikt', diplomacy: 'Diplomatie',
economic: '\u00d6konomie', legal: 'Recht', neutral: 'Neutral',
};
if (Object.keys(grouped).length > 0) {
html += '<div style="border-top:1px solid #334155;padding-top:10px;">';
html += '<strong style="color:#cbd5e1;font-size:12px;">Verbindungen (' + connected.length + '):</strong>';
Object.keys(grouped).sort().forEach(cat => {
const catColor = this._colorMap.edge[cat] || this._colorMap.edge.neutral;
const catLabel = categoryLabels[cat] || cat;
html += '<div style="margin-top:8px;">';
html += '<span style="color:' + catColor + ';font-size:11px;font-weight:600;text-transform:uppercase;">' +
this._esc(catLabel) + '</span>';
grouped[cat].forEach(item => {
const r = item.relation;
const otherName = item.other ? item.other.name : '?';
const direction = r.source_entity_id === entity.id ? '\u2192' : '\u2190';
html += '<div style="color:#94a3b8;font-size:12px;padding:2px 0 2px 8px;">';
html += direction + ' <span style="color:#e2e8f0;">' + this._esc(otherName) + '</span>';
if (r.label) html += ' &mdash; ' + this._esc(r.label);
html += ' <span style="color:#64748b;">(G:' + r.weight + ')</span>';
html += '</div>';
});
html += '</div>';
});
html += '</div>';
}
panel.innerHTML = html;
panel.style.display = 'block';
},
/**
* Filter nodes by entity type.
* @param {Set|Array} types – entity_type values to show. Empty = all.
*/
filterByType(types) {
this._filters.types = types instanceof Set ? types : new Set(types);
this._filters._isolateId = null;
this.render();
},
/**
* Filter edges by minimum weight.
* @param {number} minWeight
*/
filterByWeight(minWeight) {
this._filters.minWeight = minWeight;
this.render();
},
/**
* Highlight nodes matching the search term (name, aliases, description).
* @param {string} term
*/
search(term) {
this._filters.searchTerm = (term || '').trim().toLowerCase();
this.render();
},
/**
* Show only the 1-hop neighbourhood of the given entity.
* @param {string} entityId
*/
isolateCluster(entityId) {
this._filters._isolateId = entityId;
this.render();
},
/**
* Reset zoom, filters and selection to initial state.
*/
resetView() {
this._filters = { types: new Set(), minWeight: 1, searchTerm: '' };
this._selectedNode = null;
this._clearDetailPanel();
if (this._svg && this._zoom) {
this._svg.transition().duration(500).call(
this._zoom.transform, d3.zoomIdentity
);
}
this.render();
},
// ---- export ---------------------------------------------------------------
/**
* Export the current graph as a PNG image.
*/
exportPNG() {
if (!this._svg) return;
const svgNode = this._svg.node();
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgNode);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = function () {
const canvas = document.createElement('canvas');
const bbox = svgNode.getBoundingClientRect();
canvas.width = bbox.width * 2; // 2x for retina
canvas.height = bbox.height * 2;
const ctx = canvas.getContext('2d');
ctx.scale(2, 2);
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, bbox.width, bbox.height);
ctx.drawImage(img, 0, 0, bbox.width, bbox.height);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}, 'image/png');
};
img.src = url;
},
/**
* Export the current relations as CSV.
*/
exportCSV() {
if (!this._data) return;
const entityMap = {};
this._data.entities.forEach(e => { entityMap[e.id] = e.name; });
const rows = [['source', 'target', 'category', 'label', 'weight', 'description'].join(',')];
this._data.relations.forEach(r => {
rows.push([
this._csvField(entityMap[r.source_entity_id] || r.source_entity_id),
this._csvField(entityMap[r.target_entity_id] || r.target_entity_id),
this._csvField(r.category),
this._csvField(r.label),
r.weight,
this._csvField(r.description || ''),
].join(','));
});
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
/**
* Export the full data as JSON.
*/
exportJSON() {
if (!this._data) return;
const exportData = {
entities: this._data.entities.map(e => ({
id: e.id,
name: e.name,
name_normalized: e.name_normalized,
entity_type: e.entity_type,
description: e.description,
aliases: e.aliases,
mention_count: e.mention_count,
corrected_by_opus: e.corrected_by_opus,
metadata: e.metadata,
})),
relations: this._data.relations.map(r => ({
id: r.id,
source_entity_id: r.source_entity_id,
target_entity_id: r.target_entity_id,
category: r.category,
label: r.label,
description: r.description,
weight: r.weight,
status: r.status,
evidence: r.evidence,
})),
};
const blob = new Blob(
[JSON.stringify(exportData, null, 2)],
{ type: 'application/json;charset=utf-8' }
);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
// ---- internal helpers -----------------------------------------------------
/**
* Prepare / clone data so we do not mutate the original.
*/
_prepareData(raw) {
return {
entities: (raw.entities || []).map(e => ({ ...e })),
relations: (raw.relations || []).map(r => ({ ...r })),
};
},
/**
* Create SVG arrow markers for each edge category.
*/
_createMarkers() {
const defs = this._svg.append('defs');
const categories = Object.keys(this._colorMap.edge);
categories.forEach(cat => {
defs.append('marker')
.attr('id', 'ng-arrow-' + cat)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10)
.attr('refY', 0)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', this._colorMap.edge[cat]);
});
},
/**
* Create SVG glow filter for top-5 nodes.
*/
_createGlowFilter() {
const defs = this._svg.select('defs');
const filter = defs.append('filter')
.attr('id', 'ng-glow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
filter.append('feGaussianBlur')
.attr('in', 'SourceGraphic')
.attr('stdDeviation', 4)
.attr('result', 'blur');
filter.append('feColorMatrix')
.attr('in', 'blur')
.attr('type', 'matrix')
.attr('values', '0 0 0 0 0.98 0 0 0 0 0.75 0 0 0 0 0.14 0 0 0 0.7 0')
.attr('result', 'glow');
const merge = filter.append('feMerge');
merge.append('feMergeNode').attr('in', 'glow');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
},
/**
* d3 drag behaviour.
*/
_drag(simulation) {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
},
/**
* Handle node click – highlight edges, show detail panel.
*/
_onNodeClick(d, linkSel, nodeSel) {
this._selectedNode = d;
// Highlight selected node
nodeSel.select('circle')
.attr('stroke', n => n.id === d.id ? '#FBBF24' : '#0f172a')
.attr('stroke-width', n => n.id === d.id ? 3 : 1.5);
// Highlight connected edges
const connectedNodeIds = new Set([d.id]);
linkSel.each(function (l) {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
if (srcId === d.id || tgtId === d.id) {
connectedNodeIds.add(srcId);
connectedNodeIds.add(tgtId);
}
});
linkSel.attr('stroke-opacity', l => {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
if (srcId === d.id || tgtId === d.id) {
return Math.min(1, 0.3 + l.weight * 0.14) + 0.3;
}
return 0.08;
});
nodeSel.select('circle').attr('opacity', n =>
connectedNodeIds.has(n.id) ? 1 : 0.25
);
nodeSel.select('text').attr('opacity', n =>
connectedNodeIds.has(n.id) ? 1 : 0.2
);
// Detail panel
const entity = this._data.entities.find(e => e.id === d.id);
if (entity) {
this._updateDetailPanel(entity);
}
},
/**
* Apply search highlighting (glow matching, dim rest).
*/
_applySearchHighlight(nodeSel) {
const term = this._filters.searchTerm;
if (!term) return;
nodeSel.each(function (d) {
const matches = NetworkGraph._matchesSearch(d, term);
d3.select(this).select('circle')
.attr('opacity', matches ? 1 : 0.15)
.attr('filter', matches ? 'url(#ng-glow)' : null);
d3.select(this).select('text')
.attr('opacity', matches ? 1 : 0.1);
});
},
/**
* Check if entity matches the search term.
*/
_matchesSearch(entity, term) {
if (!term) return true;
if (entity.name && entity.name.toLowerCase().includes(term)) return true;
if (entity.name_normalized && entity.name_normalized.toLowerCase().includes(term)) return true;
if (entity.description && entity.description.toLowerCase().includes(term)) return true;
if (entity.aliases) {
for (let i = 0; i < entity.aliases.length; i++) {
if (entity.aliases[i].toLowerCase().includes(term)) return true;
}
}
return false;
},
/**
* Clear the detail panel.
*/
_clearDetailPanel() {
const panel = document.getElementById('network-detail-panel');
if (panel) {
panel.innerHTML = '<p style="color:#64748b;font-size:13px;padding:16px;">Klicke auf einen Knoten, um Details anzuzeigen.</p>';
}
},
// ---- tooltip helpers ------------------------------------------------------
_showTooltip(event, html) {
if (!this._tooltip) return;
this._tooltip
.style('display', 'block')
.html(html);
this._moveTooltip(event);
},
_moveTooltip(event) {
if (!this._tooltip) return;
this._tooltip
.style('left', (event.offsetX + 14) + 'px')
.style('top', (event.offsetY - 10) + 'px');
},
_hideTooltip() {
if (!this._tooltip) return;
this._tooltip.style('display', 'none');
},
// ---- string helpers -------------------------------------------------------
_esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
},
_csvField(val) {
const s = String(val == null ? '' : val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
},
};

592
src/static/js/pipeline.js Normale Datei
Datei anzeigen

@@ -0,0 +1,592 @@
/**
* Pipeline-Modul: Visualisierung der Analysepipeline pro Lage.
*
* - Liest Pipeline-Definition + letzten Refresh-Stand vom Backend
* (GET /api/incidents/{id}/pipeline)
* - Hört auf WebSocket-Events vom Typ "pipeline_step" und animiert Live
* den jeweils aktiven Schritt
* - Bei Lagen-Wechsel wird die Visualisierung an die neue Lage neu gebunden
*
* Stilkonzept:
* - Blöcke = Karten mit Icon + Titel + Zahl
* - Verbindungspfeile als SVG zwischen den Blöcken
* - Aktiver Block: pulsierender Glow (CSS-Klasse .is-active)
* - Fertiger Block: Häkchen + dezente Outline (.is-done)
* - Übersprungener Block: ausgeblendet (laut Anforderung)
* - Multi-Pass (Research): am letzten Block leuchtet ein Schleifen-Pfeil auf
*/
const Pipeline = {
_incidentId: null,
_definition: null, // PIPELINE_STEPS vom Backend
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
_snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore)
_isResearch: false,
_passTotal: 1,
_lastRefreshHeader: null,
_hoverTooltipEl: null,
_isLoading: false,
_wsBound: false,
_icons: {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>',
rss: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.5"/></svg>',
'copy-x': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="13" height="13" rx="2"/><path d="M8 21h11a2 2 0 0 0 2-2V8"/><path d="M11 11l4 4M15 11l-4 4"/></svg>',
scale: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8h14"/><path d="M5 8l-3 7h6z"/><path d="M19 8l-3 7h6z"/></svg>',
'map-pin': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s7-7 7-13a7 7 0 0 0-14 0c0 6 7 13 7 13z"/><circle cx="12" cy="9" r="2.5"/></svg>',
'file-text': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h8M8 9h2"/></svg>',
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>',
'check-circle': '<svg 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="M8 12l3 3 5-6"/></svg>',
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>',
},
/** Wird einmal beim Seitenstart aufgerufen, hängt sich an WebSocket. */
init() {
if (this._wsBound) return;
if (typeof WS !== 'undefined' && WS.on) {
WS.on('pipeline_step', (msg) => this._onWsStep(msg));
// Erfolg: API-State neu laden (finaler Stand sichtbar)
WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg));
// Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt
WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg));
WS.on('refresh_error', (msg) => this._onRefreshDoneError(msg));
this._wsBound = true;
}
// Hover-Tooltip-Element vorbereiten
if (!this._hoverTooltipEl) {
const t = document.createElement('div');
t.className = 'pipeline-tooltip';
t.setAttribute('role', 'tooltip');
document.body.appendChild(t);
this._hoverTooltipEl = t;
}
// Klick auf Body schliesst Tooltip-Popup
document.addEventListener('click', (e) => {
if (!e.target.closest('.pipeline-block') && !e.target.closest('.pipeline-popup')) {
this._closePopup();
}
});
},
/** Bindet die Pipeline an eine Lage. Lädt Daten und rendert. */
async bindToIncident(incidentId) {
this._incidentId = incidentId;
this._stateByKey = {};
this._snapshotState = null; // Snapshot ist immer lagen-spezifisch
this._isResearch = false;
this._passTotal = 1;
this._lastRefreshHeader = null;
this._renderEmpty('Lade...');
if (incidentId == null) return;
this._isLoading = true;
try {
const data = await API.getPipeline(incidentId);
// Lagen-Wechsel waehrend Request: alte Antwort verwerfen
if (this._incidentId !== incidentId) return;
this._definition = data.steps_definition || [];
this._isResearch = !!data.is_research;
this._lastRefreshHeader = data.last_refresh || null;
this._passTotal = (data.last_refresh && data.last_refresh.pass_total) || 1;
// Letzten Stand pro step_key konsolidieren (bei Multi-Pass: letzter Pass-Eintrag gewinnt)
(data.steps || []).forEach(s => {
const key = s.step_key;
const prev = this._stateByKey[key];
if (!prev || (s.pass_number || 1) >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: s.status,
count_value: s.count_value,
count_secondary: s.count_secondary,
pass_number: s.pass_number || 1,
};
}
});
this._render();
this._renderMini();
// Edge-Case: Lage ist gerade in Queue (z.B. via Lagen-Wechsel beim
// Klick in der Sidebar). API liefert den LETZTEN gespeicherten Stand
// (alles done = gruen), aber tatsaechlich wartet ein neuer Refresh.
// -> beginQueue() selbst ausloesen, damit Icons grau zeigen.
try {
if (typeof App !== 'undefined' && App._refreshingIncidents
&& App._refreshingIncidents.has(incidentId)
&& typeof UI !== 'undefined' && UI._progressState
&& UI._progressState[incidentId]
&& UI._progressState[incidentId].step === 'queued') {
this.beginQueue(incidentId);
}
} catch (e) { /* tolerant */ }
} catch (e) {
console.warn('Pipeline laden fehlgeschlagen:', e);
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
} finally {
this._isLoading = false;
}
},
/** WebSocket: einzelner Pipeline-Schritt-Status. */
_onWsStep(msg) {
if (!msg || !msg.data) return;
if (this._incidentId == null || msg.incident_id !== this._incidentId) return;
const d = msg.data;
const key = d.step_key;
if (!key) return;
// State aktualisieren, letzter Pass gewinnt
const prev = this._stateByKey[key];
const passNr = d.pass_number || 1;
if (!prev || passNr >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: d.status,
count_value: d.count_value !== undefined ? d.count_value : (prev ? prev.count_value : null),
count_secondary: d.count_secondary !== undefined ? d.count_secondary : (prev ? prev.count_secondary : null),
pass_number: passNr,
};
}
// Multi-Pass-Erkennung: pass_number > _passTotal -> erweitern + Loop-Animation triggern
if (passNr > this._passTotal) {
this._passTotal = passNr;
// Schleifen-Pfeil aufflackern
const stage = document.getElementById('pipeline-stage');
if (stage) {
stage.classList.add('is-looping');
setTimeout(() => stage.classList.remove('is-looping'), 1500);
}
}
// Wenn der ERSTE Schritt (sources_review) auf "active" geht, beginnt ein neuer
// Refresh oder ein neuer Multi-Pass-Durchlauf — alle nachfolgenden Schritte auf
// "pending" (grau) zuruecksetzen, damit der User sieht: das ist neu und
// noch nicht durchlaufen. Sonst stehen sie als "done" vom letzten Mal da.
let didReset = false;
if (d.status === 'active' && this._definition && this._definition.length
&& key === this._definition[0].key) {
this._definition.forEach(s => {
if (s.key !== key && this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
didReset = true;
}
});
}
if (didReset) {
// Beim Reset alle Bloecke neu zeichnen, nicht nur den aktuellen
this._render();
this._renderMini();
} else {
this._patchBlock(key);
this._patchMiniBlock(key);
}
},
/**
* Wird vom Frontend gerufen, wenn ein Refresh angestossen wurde (queued).
* Macht einen Snapshot des aktuellen Pipeline-Stands (zur spaeteren Wiederherstellung
* bei Cancel/Error) und setzt dann alle Steps auf "pending" - damit der User sieht:
* "neuer Refresh laeuft an, alte gruene Haekchen sind nicht mehr aktuell".
*/
beginQueue(incidentId) {
if (this._incidentId !== incidentId) return; // andere Lage offen
if (!this._definition) return; // noch keine Pipeline-Definition geladen
// Aktuellen Stand sichern (deep-copy). Bei Mehrfach-Refresh ohne Cancel
// dazwischen wird der Snapshot bewusst ueberschrieben - er soll immer
// der "Stand kurz vor diesem Refresh" sein.
this._snapshotState = JSON.parse(JSON.stringify(this._stateByKey));
// Alle Steps auf pending setzen
this._definition.forEach(s => {
if (this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
} else {
this._stateByKey[s.key] = { status: 'pending', count_value: null, count_secondary: null, pass_number: 1 };
}
});
this._render();
this._renderMini();
},
/** Restauriert den letzten Snapshot. Rueckgabe: true bei Erfolg, false wenn keiner da war. */
_restoreSnapshot() {
if (!this._snapshotState) return false;
this._stateByKey = this._snapshotState;
this._snapshotState = null;
this._render();
this._renderMini();
return true;
},
_onRefreshDoneSuccess(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
this._snapshotState = null; // verworfen, neuer Stand wird vom API geladen
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
},
_onRefreshDoneCancel(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
if (!this._restoreSnapshot()) {
// Kein Snapshot vorhanden (z.B. Page-Reload mitten im Refresh) -> wie bisher API-Reload
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
}
},
_onRefreshDoneError(msg) {
// Wie Cancel: vorheriger Stand zurueck (nicht im Mix-Zustand stehenbleiben)
this._onRefreshDoneCancel(msg);
},
/** Vollbild-Pipeline (Tab "Analysepipeline") als 3x3-Snake rendern. */
_render() {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (!stage) return;
if (meta) meta.textContent = this._formatHeader();
if (sidenote) sidenote.hidden = !this._isResearch;
// Brandneue Lage ohne Refresh
if (!this._lastRefreshHeader) {
this._renderEmpty('Noch nie aktualisiert. Starte den ersten Refresh.');
return;
}
// Sichtbare Blöcke (skipped komplett ausgeblendet, Anforderung 4b)
const visible = (this._definition || []).filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
// In Dreier-Reihen aufteilen, Snake-Direction abwechselnd
const ROW_SIZE = 3;
const rows = [];
for (let i = 0; i < visible.length; i += ROW_SIZE) {
rows.push({
steps: visible.slice(i, i + ROW_SIZE),
direction: (rows.length % 2 === 0) ? 'ltr' : 'rtl',
});
}
let trackHtml = '';
rows.forEach((row, rowIdx) => {
const isLastRow = rowIdx === rows.length - 1;
let rowHtml = `<div class="pipeline-row" data-direction="${row.direction}">`;
row.steps.forEach((s, i) => {
const isLastBlockOverall = isLastRow && i === row.steps.length - 1;
rowHtml += this._renderBlock(s, isLastBlockOverall);
// Inner-Pfeil zwischen Blöcken einer Reihe (nicht hinter dem letzten)
if (i < row.steps.length - 1) {
rowHtml += `<div class="pipeline-arrow" data-from="${s.key}" data-arrow-type="inner"></div>`;
}
});
rowHtml += '</div>';
trackHtml += rowHtml;
// U-Turn-Pfeil zwischen dieser und der nächsten Reihe
if (!isLastRow) {
const lastInRow = row.steps[row.steps.length - 1];
const side = row.direction === 'ltr' ? 'right' : 'left';
trackHtml += this._renderUturn(side, lastInRow.key);
}
});
stage.innerHTML = `<div class="pipeline-track">${trackHtml}</div>`;
this._bindBlockEvents(stage);
},
_renderBlock(stepDef, isLastOverall) {
const st = this._stateByKey[stepDef.key];
const status = (st && st.status) || 'pending';
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const loopMark = isLastOverall && this._isResearch
? `<div class="pipeline-loop" title="Mehrfach-Durchlauf"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg></div>`
: '';
const icon = this._icons[stepDef.icon] || this._icons.search;
return `
<div class="pipeline-block status-${status}" data-step-key="${stepDef.key}" tabindex="0" aria-label="${this._escape(stepDef.label)}">
<div class="pipeline-block-icon">${icon}</div>
<div class="pipeline-block-title">${this._escape(stepDef.label)}</div>
<div class="pipeline-block-count">${this._formatCount(stepDef.key, cv, cs, status)}</div>
<div class="pipeline-block-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-11"/></svg>
</div>
${loopMark}
</div>
`;
},
/** Kompakter Reihenwechsel-Pfeil: kurzer ↓ direkt unter dem letzten Block der oberen Reihe. */
_renderUturn(side, fromKey) {
const arrowSvg = `
<div class="uturn-arrow">
<svg viewBox="0 0 24 32" preserveAspectRatio="xMidYMid meet">
<path d="M 12 2 L 12 24" class="pipeline-uturn-path"/>
<polyline points="6,18 12,24 18,18" class="pipeline-uturn-head"/>
</svg>
</div>`;
const spacers = '<span class="uturn-spacer"></span><span class="uturn-spacer"></span>';
const inner = side === 'right' ? (spacers + arrowSvg) : (arrowSvg + spacers);
return `
<div class="pipeline-uturn" data-side="${side}" data-from="${fromKey}" data-arrow-type="uturn" aria-hidden="true">
${inner}
</div>
`;
},
/** Einzelnen Block neu zeichnen (ohne kompletten Re-Render). */
_patchBlock(stepKey) {
const stage = document.getElementById('pipeline-stage');
if (!stage) return;
const def = (this._definition || []).find(s => s.key === stepKey);
if (!def) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
// Übersprungene komplett ausblenden -> kompletter Re-Render
if (status === 'skipped') {
this._render();
return;
}
const block = stage.querySelector(`.pipeline-block[data-step-key="${stepKey}"]`);
if (!block) {
// Block fehlt im DOM (z.B. vorher skipped): kompletter Re-Render
this._render();
return;
}
block.className = `pipeline-block status-${status}`;
block.setAttribute('tabindex', '0');
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const cEl = block.querySelector('.pipeline-block-count');
if (cEl) cEl.innerHTML = this._formatCount(stepKey, cv, cs, status);
// Aktiven Pfeil/U-Turn zum nächsten Block markieren (alles mit data-from)
stage.querySelectorAll('.pipeline-arrow, .pipeline-uturn')
.forEach(a => a.classList.remove('is-flowing'));
if (status === 'done') {
const next = stage.querySelector(`[data-from="${stepKey}"]`);
if (next) next.classList.add('is-flowing');
}
},
_bindBlockEvents(stage) {
stage.querySelectorAll('.pipeline-block').forEach(block => {
const key = block.getAttribute('data-step-key');
const def = (this._definition || []).find(s => s.key === key);
if (!def) return;
block.addEventListener('mouseenter', (e) => this._showTooltip(e, def));
block.addEventListener('mouseleave', () => this._hideTooltip());
block.addEventListener('focus', (e) => this._showTooltip(e, def));
block.addEventListener('blur', () => this._hideTooltip());
block.addEventListener('click', (e) => {
e.stopPropagation();
this._openPopup(def);
});
block.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openPopup(def);
}
});
});
},
_showTooltip(evt, def) {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.textContent = def.tooltip || def.label;
this._hoverTooltipEl.classList.add('visible');
const rect = evt.currentTarget.getBoundingClientRect();
const tipW = 280;
let left = rect.left + rect.width / 2 - tipW / 2;
if (left < 8) left = 8;
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
this._hoverTooltipEl.style.left = left + 'px';
this._hoverTooltipEl.style.top = (rect.top - 8) + 'px';
this._hoverTooltipEl.style.transform = 'translateY(-100%)';
},
_hideTooltip() {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.classList.remove('visible');
},
_openPopup(def) {
this._closePopup();
const popup = document.createElement('div');
popup.className = 'pipeline-popup';
popup.setAttribute('role', 'dialog');
popup.innerHTML = `
<div class="pipeline-popup-inner">
<div class="pipeline-popup-title">${this._escape(def.label)}</div>
<div class="pipeline-popup-text">${this._escape(def.tooltip || '')}</div>
<button class="pipeline-popup-close" aria-label="Schliessen">&times;</button>
</div>
`;
popup.querySelector('.pipeline-popup-close').addEventListener('click', () => this._closePopup());
document.body.appendChild(popup);
// ESC schliesst
this._escListener = (e) => { if (e.key === 'Escape') this._closePopup(); };
document.addEventListener('keydown', this._escListener);
},
_closePopup() {
const existing = document.querySelector('.pipeline-popup');
if (existing) existing.remove();
if (this._escListener) {
document.removeEventListener('keydown', this._escListener);
this._escListener = null;
}
},
/** Mini-Variante (Refresh-Popup): Icons + Status, keine Zahlen, keine Tooltips. */
_renderMini() {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
if (!this._definition || !this._definition.length) {
mini.innerHTML = '';
return;
}
const visible = this._definition.filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
const html = visible.map((s, i) => {
const st = this._stateByKey[s.key];
const status = (st && st.status) || 'pending';
const icon = this._icons[s.icon] || this._icons.search;
const sep = (i < visible.length - 1) ? '<span class="pipeline-mini-sep" aria-hidden="true"></span>' : '';
return `<span class="pipeline-mini-block status-${status}" data-step-key="${s.key}" title="${this._escape(s.label)}">${icon}</span>${sep}`;
}).join('');
mini.innerHTML = html;
},
_patchMiniBlock(stepKey) {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
if (status === 'skipped') {
this._renderMini();
return;
}
const el = mini.querySelector(`.pipeline-mini-block[data-step-key="${stepKey}"]`);
if (!el) {
this._renderMini();
return;
}
el.className = `pipeline-mini-block status-${status}`;
},
_renderEmpty(msg) {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (meta) meta.textContent = '';
if (sidenote) sidenote.hidden = true;
if (stage) stage.innerHTML = `<div class="pipeline-empty">${msg}</div>`;
// Mini im Refresh-Popup zuruecksetzen
const mini = document.getElementById('progress-pipeline-mini');
if (mini) mini.innerHTML = '';
},
_formatHeader() {
const r = this._lastRefreshHeader;
if (!r) return '';
let parts = [];
if (r.started_at) {
const rel = this._relativeTime(r.started_at);
parts.push(rel ? `Letzter Refresh: ${rel}` : `Letzter Refresh: ${r.started_at}`);
}
if (r.duration_sec != null) {
parts.push(`Dauer: ${r.duration_sec} s`);
}
if (r.status === 'running') {
parts = ['Aktualisierung läuft...'];
} else if (r.status === 'cancelled') {
parts.push('abgebrochen');
} else if (r.status === 'error') {
parts.push('mit Fehler beendet');
}
return parts.join(' · ');
},
_relativeTime(dbStr) {
try {
// dbStr ist lokal "YYYY-MM-DD HH:MM:SS"
const d = new Date(dbStr.replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
const diffMs = Date.now() - d.getTime();
const min = Math.floor(diffMs / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std`;
const days = Math.floor(h / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
} catch (e) {
return '';
}
},
_formatCount(stepKey, cv, cs, status) {
// Qualitaetscheck: KEINE Zahlen, nur Status (Anforderung 3 vom User)
if (stepKey === 'qc' || stepKey === 'summary') {
if (status === 'done') return '<span class="count-status">erledigt</span>';
if (status === 'active') return '<span class="count-status">läuft...</span>';
if (status === 'error') return '<span class="count-status">Fehler</span>';
return '<span class="count-status">-</span>';
}
if (status === 'pending') return '<span class="count-status">-</span>';
if (status === 'active') return '<span class="count-status">läuft...</span>';
if (status === 'error') return '<span class="count-status">Fehler</span>';
if (cv == null) return '<span class="count-status">-</span>';
switch (stepKey) {
case 'sources_review':
return `${cv} Quellen geprüft`;
case 'collect':
return cs != null
? `${cv} Meldungen<small> aus ${cs} Quellen</small>`
: `${cv} Meldungen`;
case 'dedup':
return cs != null
? `${cv} Duplikate<small> (${cs} verbleiben)</small>`
: `${cv} Duplikate`;
case 'relevance':
return cs != null && cs > 0
? `${cv} relevant<small> von ${cs}</small>`
: `${cv} relevant`;
case 'geoparsing':
return cs != null
? `${cv} Orte<small> aus ${cs} Meldungen</small>`
: `${cv} Orte erkannt`;
case 'factcheck':
return cs != null
? `${cv} neue Fakten<small> (${cs} gesamt)</small>`
: `${cv} Fakten geprüft`;
case 'notify':
return cv === 0 ? 'keine versendet' : `${cv} Hinweis${cv === 1 ? '' : 'e'} versendet`;
default:
return `${cv}`;
}
},
_escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
},
};
document.addEventListener('DOMContentLoaded', () => Pipeline.init());

Datei-Diff unterdrückt, da er zu groß ist Diff laden

265
src/static/js/update-system.js Normale Datei
Datei anzeigen

@@ -0,0 +1,265 @@
/**
* Update-System fuer den AegisSight Monitor.
*
* Zeigt zwei Dinge:
* 1) Beim ersten Page-Load nach einem Update -> Modal "Was ist neu?"
* mit den Eintraegen aus RELEASES.json, die der User noch nicht gesehen hat.
*
* 2) Wenn der User die Seite offen hat und im Hintergrund ein neues Update
* live geht -> kleiner Banner unten rechts:
* "Eine neue Version ist verfuegbar. [Jetzt aktualisieren]"
*
* Datenquellen (Backend):
* GET /api/version -> { commit, deployed_at }
* GET /api/release-notes -> { entries: [...], current }
*
* Persistenz im Browser:
* localStorage 'aegis_last_seen_release' -> "version"-Feld des zuletzt
* gesehenen Eintrags
*/
(function () {
'use strict';
const POLL_INTERVAL_MS = 60_000; // alle 60 Sekunden
const STORAGE_KEY = 'aegis_last_seen_release';
let initialBootCommit = null; // Commit-Hash beim Page-Load
let pollTimer = null;
let updateBannerShown = false;
// ---- Mini-DOM-Helpers ----
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
for (const k in (attrs || {})) {
if (k === 'class') e.className = attrs[k];
else if (k === 'html') e.innerHTML = attrs[k];
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
else e.setAttribute(k, attrs[k]);
}
for (const c of children) {
if (c == null) continue;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return e;
}
// ---- Styles inline injecten (kein zusaetzlicher CSS-File noetig) ----
// Nutzt die globalen Theme-Variablen aus style.css, damit Banner und
// Modal automatisch dem Hell-/Dunkelmodus folgen.
function injectStyles() {
if (document.getElementById('aegis-update-styles')) return;
const css = `
#aegis-update-banner {
position: fixed; bottom: 24px; right: 24px; z-index: 99999;
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
padding: 14px 18px; border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
font-family: 'Inter', -apple-system, sans-serif; font-size: 0.92rem;
display: flex; align-items: center; gap: 12px; max-width: 380px;
animation: aegis-slide-in 0.4s cubic-bezier(0.4,0,0.2,1);
}
@keyframes aegis-slide-in {
from { transform: translateX(420px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
#aegis-update-banner b { font-weight: 700; color: var(--accent); }
#aegis-update-banner button {
background: var(--accent); color: #fff; border: 0; padding: 7px 14px;
border-radius: 6px; font: inherit; font-size: 0.86rem; font-weight: 600;
cursor: pointer; flex-shrink: 0;
}
#aegis-update-banner button:hover { background: var(--accent-hover); }
#aegis-update-banner .close {
background: transparent; color: var(--text-secondary); padding: 0 4px;
font-size: 1.2rem; line-height: 1;
}
#aegis-update-banner .close:hover { color: var(--text-primary); background: transparent; }
#aegis-update-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 99998;
backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; padding: 24px;
animation: aegis-fade-in 0.25s ease;
}
@keyframes aegis-fade-in { from { opacity: 0; } to { opacity: 1; } }
#aegis-update-modal {
background: var(--bg-card);
color: var(--text-primary);
border-radius: 14px;
border: 1px solid var(--border);
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
font-family: 'Inter', -apple-system, sans-serif;
max-width: 540px; width: 100%; max-height: 80vh; overflow: hidden;
display: flex; flex-direction: column;
}
#aegis-update-modal header {
padding: 22px 28px 18px; border-bottom: 1px solid var(--border);
}
#aegis-update-modal h2 { margin: 0 0 4px; color: var(--accent); font-size: 1.25rem; font-weight: 700; }
#aegis-update-modal header p { margin: 0; color: var(--text-secondary); font-size: 0.88rem; }
#aegis-update-modal .body { padding: 8px 28px; overflow-y: auto; }
.aegis-release { padding: 16px 0; border-bottom: 1px solid var(--border); }
.aegis-release:last-child { border: 0; }
.aegis-release-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }
.aegis-release-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
.aegis-release-date { font-size: 0.78rem; color: var(--text-tertiary); }
.aegis-release-items { margin: 0; padding-left: 20px; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.6; }
.aegis-release-items li { margin-bottom: 4px; }
#aegis-update-modal footer {
padding: 16px 28px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end;
}
#aegis-update-modal footer button {
background: var(--accent); color: #fff; border: 0; padding: 10px 22px;
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
cursor: pointer;
}
#aegis-update-modal footer button:hover { background: var(--accent-hover); }
@media (max-width: 600px) {
#aegis-update-banner { left: 12px; right: 12px; bottom: 12px; max-width: none; }
}`;
document.head.appendChild(el('style', { id: 'aegis-update-styles', html: css }));
}
// ---- Backend-Kommunikation ----
async function fetchVersion() {
try {
const r = await fetch('/api/version', { cache: 'no-store' });
if (!r.ok) return null;
return await r.json();
} catch (e) {
return null;
}
}
async function fetchReleaseNotes(since) {
try {
const url = '/api/release-notes' + (since ? '?since=' + encodeURIComponent(since) : '');
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) return null;
return await r.json();
} catch (e) {
return null;
}
}
// ---- Banner ----
function showUpdateBanner() {
if (updateBannerShown) return;
if (document.getElementById('aegis-update-banner')) return;
updateBannerShown = true;
const banner = el('div', { id: 'aegis-update-banner' },
el('div', null,
el('b', null, 'Update verfügbar'),
document.createElement('br'),
el('span', { style: 'font-size:0.85rem;opacity:0.85' },
'Eine neue Version ist live. Bitte Seite neu laden, um sie zu nutzen.')
),
el('button', { onclick: () => location.reload() }, 'Aktualisieren'),
el('button', {
class: 'close', title: 'Schließen',
onclick: () => banner.remove()
}, '×')
);
document.body.appendChild(banner);
}
// ---- Modal ----
function showWhatsNewModal(entries, currentVersion) {
if (document.getElementById('aegis-update-modal-overlay')) return;
if (!entries || !entries.length) return;
const releases = entries.map(e => {
const items = (e.items || []).map(i => el('li', null, i));
return el('div', { class: 'aegis-release' },
el('div', { class: 'aegis-release-head' },
el('span', { class: 'aegis-release-title' }, e.title || 'Update'),
el('span', { class: 'aegis-release-date' }, e.date || '')
),
items.length ? el('ul', { class: 'aegis-release-items' }, ...items) : null
);
});
const overlay = el('div', { id: 'aegis-update-modal-overlay' },
el('div', { id: 'aegis-update-modal' },
el('header', null,
el('h2', null, 'Was ist neu?'),
el('p', null, 'Diese Änderungen sind seit deinem letzten Besuch dazugekommen.')
),
el('div', { class: 'body' }, ...releases),
el('footer', null,
el('button', {
onclick: () => {
// Hoechste (= neueste) Version als gesehen markieren
const newest = entries[0]?.version;
if (newest) localStorage.setItem(STORAGE_KEY, newest);
overlay.remove();
}
}, 'Verstanden')
)
)
);
// ESC oder Klick auf Hintergrund -> wie "Verstanden"
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) {
const newest = entries[0]?.version;
if (newest) localStorage.setItem(STORAGE_KEY, newest);
overlay.remove();
}
});
document.addEventListener('keydown', function escHandler(ev) {
if (ev.key === 'Escape' && document.getElementById('aegis-update-modal-overlay')) {
const newest = entries[0]?.version;
if (newest) localStorage.setItem(STORAGE_KEY, newest);
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
});
document.body.appendChild(overlay);
}
// ---- Polling ----
async function pollVersion() {
const v = await fetchVersion();
if (v && v.commit && initialBootCommit && v.commit !== initialBootCommit) {
showUpdateBanner();
// Polling beenden, sobald Banner gezeigt
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
}
// ---- Initial-Boot ----
async function init() {
injectStyles();
const v = await fetchVersion();
if (v && v.commit) initialBootCommit = v.commit;
// Was-ist-neu-Modal: nur wenn Eintraege NEUER als 'lastSeen' existieren
const lastSeen = localStorage.getItem(STORAGE_KEY);
const notes = await fetchReleaseNotes(lastSeen);
if (notes && notes.entries && notes.entries.length > 0) {
// Modal mit etwas Verzoegerung zeigen, damit das Dashboard erst rendert.
// Auch beim allerersten Besuch wird das Modal gezeigt — damit Kunden
// beim Onboarding sehen, was das Update-System leistet bzw. welche
// Highlights aktuell live sind.
setTimeout(() => showWhatsNewModal(notes.entries, v?.commit), 800);
}
// Polling starten
pollTimer = setInterval(pollVersion, POLL_INTERVAL_MS);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

Datei anzeigen

@@ -34,6 +34,10 @@ const WS = {
console.log('WebSocket verbunden');
this.reconnectDelay = 2000;
this._startPing();
// Nach Reconnect: Refresh-Status mit Server abgleichen
if (typeof App !== 'undefined' && App.syncRefreshStatus) {
App.syncRefreshStatus();
}
return;
}
try {