Commits vergleichen
240 Commits
acb3c6a6cb
...
develop
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
f73c21235e | ||
|
|
9078489d0a | ||
| 24d7500152 | |||
|
|
f0fe35b279 | ||
|
|
fb6e9fff19 | ||
|
|
b1a0e97a34 | ||
|
|
77797f6027 | ||
|
|
dc51ecafe8 | ||
|
|
31fa17465a | ||
|
|
2a654cc882 | ||
|
|
6293cef91e | ||
|
|
a6f36be9c6 | ||
|
|
98c9da64b0 | ||
|
|
307f0a1868 | ||
|
|
430541f49b | ||
|
|
ee83f38edf | ||
| 2b1e8c3632 | |||
| b1f8113207 | |||
| 26fac0e824 | |||
| 62c0be64ee | |||
| 8c4ef6b2cf | |||
| ad5b723d79 | |||
| 51615cae62 | |||
| a2610d0094 | |||
| a08df3d121 | |||
| 0a6208c289 | |||
| 19038472cf | |||
| 462127dc52 | |||
| 34aeb04a88 | |||
| b14fe31f42 | |||
| ffb8dddc4f | |||
|
|
0edbf7e3b8 | ||
|
|
de01ab71fc | ||
|
|
86a49e082c | ||
|
|
221b21cb4e | ||
| 30cb276ec6 | |||
| cae9c5467a | |||
| 58eb1298ca | |||
| 370bb94b26 | |||
| c9bd6310ae | |||
| 392028a9aa | |||
| 7b5adccf2b | |||
| 059a9a2dc7 | |||
| 3a346ba2ec | |||
| 2b51e49d0d | |||
|
|
e3fe7fac85 | ||
|
|
88b18d0775 | ||
|
|
682828ea58 | ||
| ac5160010d | |||
|
|
059395393c | ||
|
|
14d1062583 | ||
|
|
2ee90a4b3b | ||
| d1f88c9e9f | |||
|
|
ad53786a24 | ||
| a9806a586b | |||
|
|
2aaa51e2a8 | ||
|
|
2df37cb617 | ||
|
|
5473ba3ed7 | ||
|
|
8042639d20 | ||
|
|
ec53ab27cd | ||
|
|
c73541cdbe | ||
|
|
5d5ec7c924 | ||
|
|
e8ac0d0c50 | ||
|
|
c8a8e10020 | ||
|
|
a579e2c275 | ||
|
|
efae707fa9 | ||
|
|
05b60ffb35 | ||
|
|
60b8646fe4 | ||
|
|
285df86c7b | ||
|
|
5add8d9d59 | ||
|
|
949df868ff | ||
|
|
9293e66d01 | ||
|
|
c0f68e40a5 | ||
| 0d6ad8ea90 | |||
| a302790777 | |||
| 9a43dffa6c | |||
| 194790899c | |||
|
|
34be98edaf | ||
|
|
82e46792c7 | ||
|
|
e495fa8e61 | ||
|
|
e15ed0c21e | ||
|
|
3b9e9e25c2 | ||
|
|
f05bd1a064 | ||
|
|
8a888a17a5 | ||
|
|
89ab158202 | ||
|
|
5c95d85871 | ||
|
|
2ae8b9a341 | ||
|
|
15a650bfc9 | ||
|
|
ed2ab1f3fc | ||
|
|
5127e0a42d | ||
|
|
d6c541cb95 | ||
|
|
acfc74ffe7 | ||
|
|
0ea7f9e305 | ||
|
|
def12ecf11 | ||
|
|
3379151fa7 | ||
|
|
048c347616 | ||
|
|
96463824a7 | ||
|
|
4358020c83 | ||
|
|
509165484e | ||
|
|
db662f4538 | ||
|
|
d2d958e0cd | ||
|
|
c59ba4f4af | ||
|
|
1bc8f66283 | ||
|
|
fa12d4cfd6 | ||
|
|
89cc920bdc | ||
|
|
f4f1df916e | ||
|
|
7900c38882 | ||
|
|
6cddb05b83 | ||
|
|
5a56024501 | ||
|
|
68c4e2a9c9 | ||
|
|
f2469093ee | ||
|
|
e0bcd85d90 | ||
|
|
565ce84abf | ||
|
|
e2e6a1ed7e | ||
|
|
d15afdd2af | ||
|
|
521d6ac357 | ||
|
|
3f9cc5a6e0 | ||
|
|
55c0307e68 | ||
|
|
3bf4f3debb | ||
|
|
9aa80b4aec | ||
|
|
fb0c47eee4 | ||
|
|
990ece1346 | ||
|
|
3811229ad9 | ||
|
|
c349947f71 | ||
|
|
762d8dbc1a | ||
|
|
244cc56bde | ||
|
|
9bfdf051c9 | ||
|
|
86ff35977e | ||
|
|
97ecde87c2 | ||
|
|
3f88d00b8c | ||
|
|
3356ba1ae5 | ||
|
|
ac3fe5f22b | ||
|
|
678b72e7ff | ||
|
|
c22ae854fe | ||
|
|
d3e8c0adc7 | ||
|
|
68c6666d87 | ||
|
|
b58eee2990 | ||
|
|
4a3b6ee352 | ||
|
|
8baa4b4716 | ||
|
|
144b7c05c9 | ||
|
|
c53d441c69 | ||
|
|
5bcaa4e8a1 | ||
|
|
c21fdcef05 | ||
|
|
b3c8cf2676 | ||
|
|
cb851ee72d | ||
|
|
34a173b27b | ||
|
|
779678fbcb | ||
|
|
322004e0b4 | ||
|
|
813b3d975e | ||
|
|
ebaf35ce2e | ||
|
|
0780901b61 | ||
|
|
702ae3cfcf | ||
|
|
2c3c3b256a | ||
|
|
1ce6b7e609 | ||
|
|
ca2059aca0 | ||
|
|
11d0aadc57 | ||
|
|
a84e2c108e | ||
|
|
6913c1e683 | ||
|
|
4f8400bfbd | ||
|
|
bd5952b9ae | ||
|
|
506965e3e2 | ||
|
|
a5ef9bbfbf | ||
|
|
6fc0a8c4f6 | ||
|
|
ca271f3822 | ||
|
|
912257ceef | ||
|
|
254a518dd8 | ||
|
|
d0f99f4e5b | ||
|
|
a2aaa061d4 | ||
|
|
5a695ce07c | ||
|
|
0aa2cd09a1 | ||
|
|
77c89aa13a | ||
|
|
a1c50cfd96 | ||
|
|
cc8c6fd268 | ||
|
|
93948cbc4c | ||
|
|
f7deafd14a | ||
|
|
8feaac3320 | ||
|
|
138fdd8594 | ||
|
|
dd25daa253 | ||
|
|
285dfbebce | ||
|
|
eaf8fcd124 | ||
|
|
8f1a45c1a9 | ||
|
|
5789cc1706 | ||
|
|
8a520389c5 | ||
|
|
42591ef7e0 | ||
|
|
da7f3822c1 | ||
|
|
3d270f60d3 | ||
|
|
094f2463bb | ||
|
|
e64447ab7f | ||
|
|
8212617276 | ||
|
|
b88b305716 | ||
|
|
381313ef12 | ||
|
|
d53b4552db | ||
|
|
a396d63fb2 | ||
|
|
4dc7824f51 | ||
|
|
b248c7e039 | ||
|
|
9825f4df48 | ||
|
|
7f09375aed | ||
|
|
eebbc82e3f | ||
|
|
db5aa965bd | ||
|
|
8d5eb91383 | ||
|
|
ffcf54785d | ||
|
|
18b7c1f8a0 | ||
|
|
9941ee646e | ||
|
|
b2be1358ab | ||
|
|
fdbffa7e00 | ||
|
|
d274ec237b | ||
|
|
c7d7bbbb18 | ||
|
|
f60edb42f7 | ||
|
|
a136e0625f | ||
|
|
c8279bc69b | ||
|
|
3e3273470b | ||
|
|
360f6bb872 | ||
|
|
073c11431d | ||
|
|
7a804f762c | ||
|
|
66ecde1d61 | ||
|
|
9b5c718816 | ||
|
|
8bbf7fceac | ||
|
|
e9d1f2ddb3 | ||
|
|
b712dd5572 | ||
|
|
ea96947d0f | ||
|
|
17681e62fb | ||
|
|
fe62cbbaee | ||
|
|
1159fe04a0 | ||
|
|
d299cdbdf4 | ||
|
|
7662332714 | ||
|
|
0ffc9b6fb6 | ||
|
|
485a527bf6 | ||
|
|
383fe1ca8c | ||
|
|
fc5846e878 | ||
|
|
186efd6aab | ||
|
|
2a8f395b32 | ||
|
|
412f869210 | ||
|
|
d2afd102e0 | ||
|
|
52358a4f2a | ||
|
|
69922b0566 | ||
|
|
c6b154dbba | ||
|
|
584183951f | ||
|
|
6b4af4cf2a | ||
|
|
17088e588f | ||
|
|
97997724de |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
logs/
|
logs/
|
||||||
data/
|
data/
|
||||||
.venv/
|
.venv/
|
||||||
|
data
|
||||||
|
|||||||
400
CLAUDE.md
400
CLAUDE.md
@@ -1,190 +1,195 @@
|
|||||||
# AegisSight-Monitor
|
# AegisSight-Monitor
|
||||||
|
|
||||||
> OSINT-Monitoringsystem mit KI-gestützter Nachrichtenanalyse
|
> OSINT-Lagemonitoring mit KI-gestützter Nachrichtenanalyse
|
||||||
|
|
||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
projekt: AegisSight-Monitor
|
projekt: AegisSight-Monitor
|
||||||
url: https://osint.intelsight.de
|
url: https://monitor.aegis-sight.de
|
||||||
beschreibung: "OSINT-basiertes Lagemonitoring mit Claude-KI-Agenten"
|
server: ssh monitor (46.225.141.13, User: claude-dev)
|
||||||
server: alt (91.99.192.14, User: claude-dev)
|
|
||||||
pfad: /home/claude-dev/AegisSight-Monitor
|
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
|
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)
|
service: osint-monitor.service (systemd, Port 8891, Nginx Reverse Proxy)
|
||||||
venv: /home/claude-dev/.venvs/osint/
|
venv: /home/claude-dev/.venvs/osint/ (Python 3.12)
|
||||||
status: aktiv
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technologie-Stack
|
## Technologie-Stack
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backend:
|
backend:
|
||||||
framework: FastAPI (Python 3.12)
|
framework: FastAPI + Uvicorn
|
||||||
datenbank: SQLite (WAL-Modus, aiosqlite)
|
datenbank: SQLite WAL (aiosqlite, async)
|
||||||
auth: Magic-Link-Login per E-Mail (JWT HS256, 24h Ablauf)
|
auth: Magic-Link-Login per E-Mail (JWT HS256, 24h)
|
||||||
scheduler: APScheduler (Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00)
|
scheduler: APScheduler (Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00)
|
||||||
websocket: FastAPI native (Echtzeit-Updates)
|
websocket: FastAPI native (Echtzeit-Updates an Clients)
|
||||||
ki_agenten: Claude CLI (WebSearch + WebFetch Tools)
|
ki: Claude CLI als Subprocess (WebSearch + WebFetch Tools)
|
||||||
email: aiosmtplib (Magic Links, Benachrichtigungen)
|
ki_modelle:
|
||||||
port: 8891 (localhost, Nginx Reverse Proxy)
|
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:
|
frontend:
|
||||||
typ: Vanilla JS (kein Framework)
|
typ: Vanilla JS (kein Framework, kein Build-Step)
|
||||||
design: AegisSight Dark/Light Theme (Navy/Gold)
|
design: AegisSight Dark/Light Theme (Navy/Gold)
|
||||||
fonts: Poppins (Titel), Inter (Body)
|
fonts: Poppins (Titel), Inter (Body)
|
||||||
layout: gridstack.js (Drag-and-Drop Dashboard-Kacheln)
|
layout: gridstack.js (Drag-and-Drop Dashboard-Kacheln)
|
||||||
|
karte: Leaflet + MarkerCluster
|
||||||
echtzeit: WebSocket mit Auto-Reconnect und Ping/Pong
|
echtzeit: WebSocket mit Auto-Reconnect und Ping/Pong
|
||||||
```
|
```
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```yaml
|
```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/:
|
src/:
|
||||||
main.py: "FastAPI App, WebSocketManager, Scheduler (lifespan), statische Routen"
|
main.py: "FastAPI App, WebSocketManager, Scheduler, Lifespan, statische Routen"
|
||||||
config.py: "Konfiguration (JWT, Claude CLI Pfad/Timeout, SMTP, RSS-Default-Feeds, Excluded Sources, Zeitzone)"
|
config.py: "Konfiguration (JWT, Claude-Modelle, SMTP, RSS-Feeds, Zeitzone)"
|
||||||
auth.py: "JWT-Token erstellen/verifizieren, Magic-Link/Code generieren, get_current_user Dependency"
|
auth.py: "JWT erstellen/verifizieren, Magic-Link/Code, get_current_user Dependency"
|
||||||
database.py: "SQLite Schema (13 Tabellen), Migrationen, init_db()"
|
database.py: "SQLite Schema (25+ Tabellen), Migrationen, init_db(), get_db()"
|
||||||
models.py: "Pydantic Request/Response-Schemas"
|
models.py: "Pydantic Request/Response-Schemas"
|
||||||
source_rules.py: "Dynamische Quellen-Regeln aus DB, Domain-Kategorisierung, Feed-Discovery"
|
source_rules.py: "Domain-Kategorisierung, RSS-Feed-Discovery, Claude-Feed-Bewertung"
|
||||||
|
report_generator.py: "PDF (WeasyPrint) + DOCX (python-docx) Export"
|
||||||
|
|
||||||
routers/:
|
routers/:
|
||||||
auth.py: "Magic-Link-Login: POST /api/auth/magic-link, /verify, /verify-code, GET /api/auth/me"
|
auth.py: "Magic-Link-Login, Token-Verify, /api/auth/me"
|
||||||
incidents.py: "CRUD Lagen, Artikel, Snapshots, Faktenchecks, Refresh, Export, E-Mail-Subscriptions"
|
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/entsperren, Stats"
|
sources.py: "CRUD Quellen, Discovery (Single/Multi), Domain sperren, Telegram-Validierung"
|
||||||
notifications.py: "GET/PUT Benachrichtigungen (Liste, ungelesen, als gelesen markieren)"
|
chat.py: "KI-Assistent (Haiku), Injection-Schutz, Tech-Leak-Filter"
|
||||||
feedback.py: "POST /api/feedback (Rate-Limited, E-Mail an feedback@aegis-sight.de)"
|
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"
|
||||||
|
|
||||||
agents/:
|
agents/:
|
||||||
claude_client.py: "Shared Claude CLI Client (JSON-Output, Usage-Tracking: Token, Kosten)"
|
orchestrator.py: "Queue-basierte Refresh-Steuerung, Research Multi-Pass (3 Durchlaeufe), Retry, Cancel, Credits-Tracking"
|
||||||
orchestrator.py: "AsyncQueue, Agenten-Pipeline, Cancel, Snapshots, E-Mail-Benachrichtigungen, Quellen-Discovery"
|
researcher.py: "WebSearch-Recherche (Standard + 4-Phasen-Tiefenrecherche), Feed-Selektion, Keyword-Extraktion"
|
||||||
researcher.py: "Claude WebSearch Agent (Ad-hoc + Deep Research Modus)"
|
analyzer.py: "Analyse-Agent (Lagebild/Briefing, Erst- + inkrementell, Inline-Zitate)"
|
||||||
analyzer.py: "Analyse-Agent (Zusammenfassung/Briefing mit Inline-Zitaten)"
|
factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Triage), Claim-Matching, Dedup"
|
||||||
factchecker.py: "Faktencheck-Agent (Claims gegen unabhaengige Quellen pruefen)"
|
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"
|
||||||
|
|
||||||
feeds/:
|
feeds/:
|
||||||
rss_parser.py: "RSS-Feed Aggregation (dynamisch aus DB, Keyword-Matching)"
|
rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), Keyword-Matching, Domain-Cap"
|
||||||
|
telegram_parser.py: "Telethon-basierter Telegram-Parser, Kanal-Validierung"
|
||||||
|
|
||||||
services/:
|
services/:
|
||||||
license_service.py: "Lizenzpruefung (check_license), Nutzer-Limit, Ablauf-Check"
|
post_refresh_qc.py: "Post-Refresh Quality Check: Faktencheck-Duplikate, Location-Korrektur"
|
||||||
source_health.py: "Quellen-Health-Check Engine (Erreichbarkeit, Feed-Validitaet, Aktualitaet, Duplikate)"
|
fact_consolidation.py: "Periodisches Haiku-Clustering, Auto-Resolve veralteter Fakten"
|
||||||
source_suggester.py: "KI-gestuetzte Quellen-Vorschlaege via Claude Haiku"
|
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)"
|
||||||
|
|
||||||
middleware/:
|
middleware/:
|
||||||
license_check.py: "FastAPI Dependencies: require_active_license, require_writable_license"
|
license_check.py: "Dependencies: require_active_license, require_writable_license"
|
||||||
|
|
||||||
migration/:
|
|
||||||
migrate_to_multitenancy.py: "Einmal-Migration: Single-Tenant zu Multi-Tenant"
|
|
||||||
|
|
||||||
email_utils/:
|
email_utils/:
|
||||||
sender.py: "Async SMTP E-Mail-Versand (aiosmtplib, TLS)"
|
sender.py: "Async SMTP Versand"
|
||||||
templates.py: "HTML-E-Mail-Templates (Magic-Link-Login, Incident-Benachrichtigungen)"
|
templates.py: "HTML-Templates (Magic-Link, Benachrichtigungen)"
|
||||||
rate_limiter.py: "Rate-Limiting fuer Magic-Links und Code-Verifizierung"
|
rate_limiter.py: "Rate-Limiting Magic-Links"
|
||||||
|
|
||||||
|
migration/:
|
||||||
|
migrate_to_multitenancy.py: "Einmal-Migration Single->Multi-Tenant"
|
||||||
|
|
||||||
|
report_templates/:
|
||||||
|
report.html: "HTML-Template fuer PDF/DOCX-Export"
|
||||||
|
|
||||||
static/:
|
static/:
|
||||||
index.html: "Login-Seite (Magic-Link: E-Mail eingeben, Code eingeben)"
|
index.html: "Login-Seite (Magic-Link)"
|
||||||
dashboard.html: "Hauptdashboard (Sidebar + Grid + Modals)"
|
dashboard.html: "Hauptdashboard (Sidebar + GridStack + Modals)"
|
||||||
css/:
|
css/:
|
||||||
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
|
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
|
||||||
js/:
|
js/:
|
||||||
api.js: "REST-API-Client (fetch-basiert, 30s Timeout, Auto-Redirect bei 401)"
|
api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout)"
|
||||||
app.js: "Hauptlogik: ThemeManager, A11yManager, NotificationCenter, App-Objekt"
|
app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt"
|
||||||
components.js: "UI-Rendering: Sidebar-Items, Faktenchecks, Evidence-Chips, Toasts, Fortschritt, Quellen"
|
components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress-Bar, Karte"
|
||||||
layout.js: "gridstack.js Wrapper (Drag und Resize, localStorage-Persistenz)"
|
chat.js: "Chat-Assistent Widget"
|
||||||
ws.js: "WebSocket-Client (Reconnect mit exponential Backoff, Ping/Pong)"
|
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
|
## Architektur
|
||||||
|
|
||||||
```yaml
|
```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:
|
incident_typen:
|
||||||
adhoc: "Breaking News: RSS + WebSearch, Fliesstext-Summary"
|
adhoc:
|
||||||
research: "Hintergrundrecherche: Deep Research, Markdown-Briefing"
|
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:
|
refresh_pipeline:
|
||||||
aktive_lagen: "Lagen mit type=adhoc und status=active"
|
1: "Feed-Selektion (Haiku) + dynamische Keywords"
|
||||||
aktive_recherchen: "Lagen mit type=research und status=active"
|
2: "Parallel: RSS + WebSearch + optional Telegram"
|
||||||
archiv: "Alle Lagen mit status=archived (standardmaessig zugeklappt)"
|
3: "URL-Verifizierung (HEAD-Requests)"
|
||||||
zaehler: "Anzahl pro Sektion in Klammern"
|
4: "Duplikaterkennung (URL + Headline)"
|
||||||
filter: "Alle / Eigene"
|
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:
|
multi_tenancy: "Volle Mandantentrennung (tenant_id auf allen Tabellen)"
|
||||||
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"
|
|
||||||
|
|
||||||
dashboard_kacheln:
|
dashboard_kacheln:
|
||||||
lagebild: "Markdown-Zusammenfassung mit klickbaren Zitaten"
|
- "Lagebild (Markdown + Inline-Zitate)"
|
||||||
faktencheck: "Status-Icons, Evidence-Chips, Filter"
|
- "Faktencheck (Status-Icons, Evidence, Filter)"
|
||||||
quellenübersicht: "Aggregiert nach Quellen mit Sprach-Statistik"
|
- "Quellenübersicht (nach Domain gruppiert)"
|
||||||
timeline: "Interaktive Zeitleiste mit Bucketing, Filtern, Suche"
|
- "Timeline (horizontale Achse, Bucketing, Filter)"
|
||||||
|
- "Karte (Leaflet, Kategorie-Marker, Legende)"
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```yaml
|
||||||
verwaltungsportal:
|
verwaltungsportal:
|
||||||
pfad: /home/claude-dev/AegisSight-Monitor-Verwaltung
|
pfad: /home/claude-dev/AegisSight-Monitor-Verwaltung
|
||||||
beschreibung: "Admin-Portal fuer Organisationen, Lizenzen, Nutzer"
|
url: https://monitor-verwaltung.aegis-sight.de
|
||||||
geteilte_db: /mnt/gitea/osint-data/osint.db
|
|
||||||
service: verwaltungsportal.service (Port 8892)
|
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
|
## Regeln
|
||||||
@@ -192,8 +197,151 @@ verwaltungsportal:
|
|||||||
```yaml
|
```yaml
|
||||||
regeln:
|
regeln:
|
||||||
- "Jede Aenderung MUSS sofort committed und nach Gitea gepusht werden"
|
- "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"
|
- "Keine Passwoerter oder Secrets in den Code committen"
|
||||||
- "Service nach Backend-Aenderungen neustarten: sudo systemctl restart osint-monitor"
|
- "Service nach Backend-Aenderungen: sudo systemctl restart osint-monitor"
|
||||||
- "Frontend-Aenderungen brauchen keinen Neustart (statische Dateien)"
|
- "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
65
RELEASES.json
Normale Datei
@@ -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
1
data
@@ -1 +0,0 @@
|
|||||||
/mnt/gitea/osint-data
|
|
||||||
@@ -11,3 +11,8 @@ python-multipart
|
|||||||
aiosmtplib
|
aiosmtplib
|
||||||
geonamescache>=2.0
|
geonamescache>=2.0
|
||||||
telethon
|
telethon
|
||||||
|
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
||||||
|
Jinja2>=3.1
|
||||||
|
weasyprint>=68.0
|
||||||
|
python-docx>=1.2
|
||||||
|
pikepdf>=9.0
|
||||||
|
|||||||
87
scripts/backfill_latest_developments.py
Normale Datei
87
scripts/backfill_latest_developments.py
Normale Datei
@@ -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))
|
||||||
78
scripts/bootstrap_umlaut_repair.py
Normale Datei
78
scripts/bootstrap_umlaut_repair.py
Normale Datei
@@ -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
166
scripts/build_umlaut_dict.py
Normale Datei
@@ -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()
|
||||||
@@ -28,7 +28,9 @@ AUFTRAG:
|
|||||||
STRUKTUR:
|
STRUKTUR:
|
||||||
- Wenn die Meldungen thematisch klar einen einzelnen Strang behandeln: Fließtext ohne Überschriften
|
- 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 (##)
|
- 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:
|
REGELN:
|
||||||
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
- 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...")
|
- Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...")
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
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://..."}}
|
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||||
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
|
- "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."""
|
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.
|
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.
|
Verwende durchgehend Inline-Quellenverweise [1], [2], [3] etc. im Text.
|
||||||
|
|
||||||
## ÜBERBLICK
|
## ZUSAMMENFASSUNG
|
||||||
Kurze Einordnung des Themas (2-3 Sätze)
|
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
|
## HINTERGRUND
|
||||||
Historischer Kontext, relevante Vorgeschichte
|
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.
|
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
|
||||||
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||||
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
|
- "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."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -130,12 +130,15 @@ AUFTRAG:
|
|||||||
|
|
||||||
STRUKTUR:
|
STRUKTUR:
|
||||||
- Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität
|
- 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
|
- 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:
|
REGELN:
|
||||||
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
||||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
||||||
- Bei widersprüchlichen Angaben beide Seiten erwähnen
|
- 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..."
|
- 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
|
- Quellen immer mit [Nr] referenzieren
|
||||||
- Ältere Quellen zeitlich einordnen
|
- Ältere Quellen zeitlich einordnen
|
||||||
@@ -144,7 +147,6 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
|||||||
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
|
- "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.
|
- "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)
|
- "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."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -169,8 +171,14 @@ NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
|
|||||||
AUFTRAG:
|
AUFTRAG:
|
||||||
Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie nötig. Behalte die Struktur bei:
|
Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie nötig. Behalte die Struktur bei:
|
||||||
|
|
||||||
## ÜBERBLICK
|
## ZUSAMMENFASSUNG
|
||||||
## HINTERGRUND
|
## 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
|
## AKTEURE
|
||||||
## AKTUELLE LAGE
|
## AKTUELLE LAGE
|
||||||
## EINSCHÄTZUNG
|
## EINSCHÄTZUNG
|
||||||
@@ -179,6 +187,7 @@ Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie n
|
|||||||
REGELN:
|
REGELN:
|
||||||
- Bisherige gesicherte Fakten beibehalten
|
- Bisherige gesicherte Fakten beibehalten
|
||||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
- 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
|
- Neue Erkenntnisse einarbeiten
|
||||||
- Veraltete Informationen aktualisieren
|
- 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..."
|
- 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
|
- "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.
|
- "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)
|
- "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."""
|
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:
|
class AnalyzerAgent:
|
||||||
"""Analysiert und übersetzt Meldungen über Claude CLI."""
|
"""Analysiert und übersetzt Meldungen über Claude CLI."""
|
||||||
|
|
||||||
@@ -242,6 +312,7 @@ class AnalyzerAgent:
|
|||||||
result, usage = await call_claude(prompt)
|
result, usage = await call_claude(prompt)
|
||||||
analysis = self._parse_response(result)
|
analysis = self._parse_response(result)
|
||||||
if analysis:
|
if analysis:
|
||||||
|
analysis = self._sanitize_sources(analysis)
|
||||||
logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert")
|
logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert")
|
||||||
return analysis, usage
|
return analysis, usage
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -303,6 +374,8 @@ class AnalyzerAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt)
|
result, usage = await call_claude(prompt)
|
||||||
analysis = self._parse_response(result)
|
analysis = self._parse_response(result)
|
||||||
|
if analysis:
|
||||||
|
analysis = self._sanitize_sources(analysis)
|
||||||
if analysis and self._all_previous_sources:
|
if analysis and self._all_previous_sources:
|
||||||
# Merge: alte Quellen beibehalten, neue hinzufuegen
|
# Merge: alte Quellen beibehalten, neue hinzufuegen
|
||||||
returned_sources = analysis.get("sources", [])
|
returned_sources = analysis.get("sources", [])
|
||||||
@@ -325,6 +398,296 @@ class AnalyzerAgent:
|
|||||||
logger.error(f"Inkrementelle Analyse-Fehler: {e}")
|
logger.error(f"Inkrementelle Analyse-Fehler: {e}")
|
||||||
return None, None
|
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:
|
def _parse_response(self, response: str) -> dict | None:
|
||||||
"""Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback."""
|
"""Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback."""
|
||||||
# Markdown-Code-Fences entfernen
|
# Markdown-Code-Fences entfernen
|
||||||
@@ -429,5 +792,5 @@ class AnalyzerAgent:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []}
|
return {"summary": summary, "sources": sources, "key_facts": []}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
"""Shared Claude CLI Client mit Usage-Tracking."""
|
"""Shared Claude CLI Client mit Usage-Tracking."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextvars
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
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")
|
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
|
@dataclass
|
||||||
class ClaudeUsage:
|
class ClaudeUsage:
|
||||||
"""Token-Verbrauch eines einzelnen Claude CLI Aufrufs."""
|
"""Token-Verbrauch eines einzelnen Claude CLI Aufrufs."""
|
||||||
@@ -38,7 +72,12 @@ class UsageAccumulator:
|
|||||||
self.call_count += 1
|
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.
|
"""Ruft Claude CLI auf. Gibt (result_text, usage) zurück.
|
||||||
|
|
||||||
Prompt wird via stdin uebergeben um OS ARG_MAX Limits zu vermeiden.
|
Prompt wird via stdin uebergeben um OS ARG_MAX Limits zu vermeiden.
|
||||||
@@ -46,15 +85,17 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
|
|||||||
Args:
|
Args:
|
||||||
prompt: Der Prompt fuer Claude
|
prompt: Der Prompt fuer Claude
|
||||||
tools: Kommagetrennte erlaubte Tools (None = keine Tools, --max-turns 1)
|
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"]
|
effective_model = model or CLAUDE_MODEL_STANDARD
|
||||||
if model:
|
effective_timeout = timeout if timeout is not None else CLAUDE_TIMEOUT
|
||||||
cmd.extend(["--model", model])
|
cmd = [CLAUDE_PATH, "-p", "-", "--output-format", "json", "--model", effective_model]
|
||||||
if tools:
|
if tools:
|
||||||
cmd.extend(["--allowedTools", tools])
|
cmd.extend(["--allowedTools", tools])
|
||||||
else:
|
else:
|
||||||
cmd.extend(["--max-turns", "1", "--allowedTools", ""])
|
cmd.extend(["--max-turns", "1", "--allowedTools", ""])
|
||||||
|
if not raw_text:
|
||||||
cmd.extend(["--append-system-prompt",
|
cmd.extend(["--append-system-prompt",
|
||||||
"CRITICAL: You are a JSON-only output agent. "
|
"CRITICAL: You are a JSON-only output agent. "
|
||||||
"Output EXCLUSIVELY a single valid JSON object. "
|
"Output EXCLUSIVELY a single valid JSON object. "
|
||||||
@@ -72,30 +113,59 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
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(
|
stdout, stderr = await asyncio.wait_for(
|
||||||
process.communicate(input=prompt.encode("utf-8")), timeout=CLAUDE_TIMEOUT
|
process.communicate(input=prompt.encode("utf-8")), timeout=effective_timeout
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
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:
|
if process.returncode != 0:
|
||||||
error_msg = stderr.decode("utf-8", errors="replace").strip()
|
error_msg = stderr.decode("utf-8", errors="replace").strip()
|
||||||
stdout_msg = stdout.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
|
# Rate-Limit/Auth-Fehler kommen teils als JSON auf stdout, nicht auf stderr
|
||||||
error_type = "cli_error"
|
combined_output = f"{error_msg} {stdout_msg}"
|
||||||
rate_limit_keywords = ["hit your limit", "rate limit", "resets", "rate_limit", "overloaded"]
|
error_type = _classify_cli_error(combined_output)
|
||||||
combined_output = f"{error_msg} {stdout_msg}".lower()
|
|
||||||
if any(kw in combined_output for kw in rate_limit_keywords):
|
if error_type == "rate_limit":
|
||||||
error_type = "rate_limit"
|
|
||||||
logger.warning(f"Claude CLI Rate-Limit (Exit {process.returncode}): {stdout_msg or error_msg}")
|
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:
|
else:
|
||||||
logger.error(f"Claude CLI Fehler (Exit {process.returncode}): {error_msg}")
|
logger.error(f"Claude CLI Fehler (Exit {process.returncode}): {error_msg}")
|
||||||
if stdout_msg:
|
if stdout_msg:
|
||||||
logger.error(f"Claude CLI stdout bei Fehler: {stdout_msg[:500]}")
|
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()
|
raw = stdout.decode("utf-8", errors="replace").strip()
|
||||||
usage = ClaudeUsage()
|
usage = ClaudeUsage()
|
||||||
@@ -103,6 +173,19 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
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)
|
result_text = data.get("result", raw)
|
||||||
u = data.get("usage", {})
|
u = data.get("usage", {})
|
||||||
usage = ClaudeUsage(
|
usage = ClaudeUsage(
|
||||||
@@ -122,4 +205,5 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning("Claude CLI Antwort kein gültiges JSON, nutze raw output")
|
logger.warning("Claude CLI Antwort kein gültiges JSON, nutze raw output")
|
||||||
|
|
||||||
|
result_text = _sanitize_mdash(result_text)
|
||||||
return result_text, usage
|
return result_text, usage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Netzwerkanalyse: Entity-Extraktion (Haiku) + Beziehungsanalyse (Batched)."""
|
"""Netzwerkanalyse: Entity-Extraktion (Sonnet) + Beziehungsanalyse (Batched) mit Artikel-Deduplizierung."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
|
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")
|
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()
|
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
|
# Entity-Merge Helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -279,8 +387,8 @@ async def _phase1_extract_entities(
|
|||||||
headline = art.get("headline_de") or art.get("headline") or ""
|
headline = art.get("headline_de") or art.get("headline") or ""
|
||||||
content = art.get("content_de") or art.get("content_original") or ""
|
content = art.get("content_de") or art.get("content_original") or ""
|
||||||
source = art.get("source") or ""
|
source = art.get("source") or ""
|
||||||
if len(content) > 2000:
|
if len(content) > 800:
|
||||||
content = content[:2000] + "..."
|
content = content[:800] + "..."
|
||||||
all_texts.append(f"[{source}] {headline}\n{content}")
|
all_texts.append(f"[{source}] {headline}\n{content}")
|
||||||
|
|
||||||
for fc in factchecks:
|
for fc in factchecks:
|
||||||
@@ -293,7 +401,7 @@ async def _phase1_extract_entities(
|
|||||||
logger.warning(f"Analyse {analysis_id}: Keine Texte vorhanden")
|
logger.warning(f"Analyse {analysis_id}: Keine Texte vorhanden")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
batch_size = 30
|
batch_size = 50
|
||||||
batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)]
|
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")
|
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)
|
prompt = ENTITY_EXTRACTION_PROMPT.format(articles_text=articles_text)
|
||||||
|
|
||||||
try:
|
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)
|
usage_acc.add(usage)
|
||||||
except Exception as e:
|
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
|
continue
|
||||||
|
|
||||||
parsed = _parse_json_response(result_text)
|
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 ""
|
headline = art.get("headline_de") or art.get("headline") or ""
|
||||||
content = art.get("content_de") or art.get("content_original") or ""
|
content = art.get("content_de") or art.get("content_original") or ""
|
||||||
source = art.get("source") or ""
|
source = art.get("source") or ""
|
||||||
if len(content) > 2000:
|
if len(content) > 800:
|
||||||
content = content[:2000] + "..."
|
content = content[:800] + "..."
|
||||||
all_texts.append(f"[{source}] {headline}\n{content}")
|
all_texts.append(f"[{source}] {headline}\n{content}")
|
||||||
|
|
||||||
for fc in factchecks:
|
for fc in factchecks:
|
||||||
@@ -514,7 +622,7 @@ async def _phase2_analyze_relationships(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# --- Stufe A: Per-Batch Beziehungsextraktion ---
|
# --- 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)]
|
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")
|
logger.info(f"Stufe A: {len(batches)} Batches für Beziehungsextraktion")
|
||||||
|
|
||||||
@@ -545,7 +653,7 @@ async def _phase2_analyze_relationships(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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)
|
usage_acc.add(usage)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Relationship Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {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, "
|
logger.info(f"Analyse {analysis_id}: {len(articles)} Artikel, "
|
||||||
f"{len(factchecks)} Faktenchecks aus {len(incident_ids)} Lagen")
|
f"{len(factchecks)} Faktenchecks aus {len(incident_ids)} Lagen")
|
||||||
|
|
||||||
|
# Artikel-Deduplizierung vor KI-Pipeline
|
||||||
|
articles, factchecks = _deduplicate_articles(articles, factchecks)
|
||||||
|
|
||||||
# Phase 1: Entity-Extraktion
|
# Phase 1: Entity-Extraktion
|
||||||
if not await _check_analysis_exists(db, analysis_id):
|
if not await _check_analysis_exists(db, analysis_id):
|
||||||
return
|
return
|
||||||
|
|||||||
1144
src/agents/entity_extractor.py.bak
Normale Datei
1144
src/agents/entity_extractor.py.bak
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -10,6 +10,7 @@ logger = logging.getLogger("osint.factchecker")
|
|||||||
|
|
||||||
FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
|
FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
VORFALL: {title}
|
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.
|
RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
THEMA: {title}
|
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.
|
INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
VORFALL: {title}
|
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.
|
INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
THEMA: {title}
|
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.
|
VERIFY_GROUP_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
THEMA DIESER GRUPPE: {theme}
|
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.
|
VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
THEMA DIESER GRUPPE: {theme}
|
THEMA DIESER GRUPPE: {theme}
|
||||||
@@ -449,7 +455,11 @@ class FactCheckerAgent:
|
|||||||
status = fc.get("status", "developing")
|
status = fc.get("status", "developing")
|
||||||
claim = fc.get("claim", "")
|
claim = fc.get("claim", "")
|
||||||
sources = fc.get("sources_count", 0)
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[list[dict], ClaudeUsage | None]:
|
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
@@ -7,14 +7,69 @@ from config import CLAUDE_MODEL_FAST
|
|||||||
|
|
||||||
logger = logging.getLogger("osint.researcher")
|
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.
|
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
|
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
|
||||||
Titel: {title}
|
Titel: {title}
|
||||||
Kontext: {description}
|
Kontext: {description}
|
||||||
{existing_context}
|
{existing_context}{preferred_sources_block}
|
||||||
REGELN:
|
REGELN:
|
||||||
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
|
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
|
||||||
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
|
- 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.
|
DEEP_RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Tiefenrecherche-Agent für ein Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
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).
|
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
|
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
|
||||||
Titel: {title}
|
Titel: {title}
|
||||||
Kontext: {description}
|
Kontext: {description}
|
||||||
{existing_context}
|
{existing_context}{preferred_sources_block}
|
||||||
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
|
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
|
||||||
|
|
||||||
PHASE 1 — BREITE ERFASSUNG:
|
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"}}]"""
|
[{{"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.
|
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}
|
LAGE: {title}
|
||||||
@@ -209,30 +283,28 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
# Neues Format: JSON-Objekt mit "feeds" und "keywords"
|
|
||||||
keywords = None
|
keywords = None
|
||||||
indices = None
|
indices = None
|
||||||
|
|
||||||
# Versuche JSON-Objekt zu parsen
|
# Neues Format: {"feeds": [...], "keywords": [...]}
|
||||||
obj_match = re.search(r'\{[^{}]*"feeds"\s*:\s*\[[\d\s,]+\][^{}]*\}', result, re.DOTALL)
|
obj = _extract_json_object(result)
|
||||||
if obj_match:
|
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
||||||
try:
|
indices = obj["feeds"]
|
||||||
obj = json.loads(obj_match.group())
|
|
||||||
indices = obj.get("feeds", [])
|
|
||||||
raw_keywords = obj.get("keywords", [])
|
raw_keywords = obj.get("keywords", [])
|
||||||
if isinstance(raw_keywords, list) and raw_keywords:
|
if isinstance(raw_keywords, list) and raw_keywords:
|
||||||
keywords = [str(k).lower().strip() for k in raw_keywords if k]
|
keywords = [str(k).lower().strip() for k in raw_keywords if k]
|
||||||
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: altes Array-Format
|
# Fallback: nacktes Array
|
||||||
if indices is None:
|
if indices is None:
|
||||||
arr_match = re.search(r'\[[\d\s,]+\]', result)
|
arr = _extract_json_array(result)
|
||||||
if not arr_match:
|
if not isinstance(arr, list):
|
||||||
logger.warning("Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds")
|
logger.warning(
|
||||||
|
"Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds. Sample: %s",
|
||||||
|
_truncate_for_log(result),
|
||||||
|
)
|
||||||
return feeds_metadata, None, usage
|
return feeds_metadata, None, usage
|
||||||
indices = json.loads(arr_match.group())
|
indices = arr
|
||||||
|
|
||||||
selected = []
|
selected = []
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
@@ -273,19 +345,12 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
parsed = None
|
parsed = _extract_json_array(result)
|
||||||
try:
|
if not isinstance(parsed, list):
|
||||||
parsed = json.loads(result)
|
logger.warning(
|
||||||
except json.JSONDecodeError:
|
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
|
||||||
match = re.search(r'\[.*\]', result, re.DOTALL)
|
_truncate_for_log(result),
|
||||||
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")
|
|
||||||
return None, usage
|
return None, usage
|
||||||
|
|
||||||
# Flache Liste: alle DE + EN Begriffe
|
# Flache Liste: alle DE + EN Begriffe
|
||||||
@@ -308,9 +373,35 @@ class ResearcherAgent:
|
|||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
return None, None
|
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]:
|
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."""
|
"""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
|
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":
|
if incident_type == "research":
|
||||||
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
|
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
|
||||||
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
||||||
@@ -330,6 +421,7 @@ class ResearcherAgent:
|
|||||||
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
|
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
|
||||||
title=title, description=description, language_instruction=lang_instruction,
|
title=title, description=description, language_instruction=lang_instruction,
|
||||||
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
||||||
|
preferred_sources_block=preferred_sources_block,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
|
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
|
||||||
@@ -348,11 +440,18 @@ class ResearcherAgent:
|
|||||||
prompt = RESEARCH_PROMPT_TEMPLATE.format(
|
prompt = RESEARCH_PROMPT_TEMPLATE.format(
|
||||||
title=title, description=description, language_instruction=lang_instruction,
|
title=title, description=description, language_instruction=lang_instruction,
|
||||||
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
||||||
|
preferred_sources_block=preferred_sources_block,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt)
|
result, usage = await call_claude(prompt)
|
||||||
|
try:
|
||||||
articles = self._parse_response(result)
|
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
|
# Ausgeschlossene Quellen dynamisch aus DB laden
|
||||||
excluded_sources = await self._get_excluded_sources(user_id=user_id)
|
excluded_sources = await self._get_excluded_sources(user_id=user_id)
|
||||||
@@ -374,13 +473,13 @@ class ResearcherAgent:
|
|||||||
filtered.append(article)
|
filtered.append(article)
|
||||||
|
|
||||||
logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
|
logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
|
||||||
return filtered, usage
|
return filtered, usage, False
|
||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recherche-Fehler: {e}")
|
logger.error(f"Recherche-Fehler: {e}")
|
||||||
return [], None
|
return [], None, False
|
||||||
|
|
||||||
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
|
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
|
||||||
"""Laedt ausgeschlossene Quellen (global + per-User)."""
|
"""Laedt ausgeschlossene Quellen (global + per-User)."""
|
||||||
@@ -403,56 +502,118 @@ class ResearcherAgent:
|
|||||||
return list(EXCLUDED_SOURCES)
|
return list(EXCLUDED_SOURCES)
|
||||||
|
|
||||||
def _parse_response(self, response: str) -> list[dict]:
|
def _parse_response(self, response: str) -> list[dict]:
|
||||||
"""Parst die Claude-Antwort als JSON-Array."""
|
"""Parst die Claude-Antwort als JSON-Array.
|
||||||
# Versuche JSON direkt zu parsen
|
|
||||||
|
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:
|
try:
|
||||||
data = json.loads(response)
|
data = json.loads(text)
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
return data
|
return data
|
||||||
if isinstance(data, dict) and "articles" in data:
|
if isinstance(data, dict) and isinstance(data.get("articles"), list):
|
||||||
return data["articles"]
|
return data["articles"]
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# JSON-Code-Block extrahieren
|
# 2) JSON-Array irgendwo im Text (Markdown-Fence oder Vor-/Nachtext)
|
||||||
code_pat = r'`{3}(?:json)?\s*\n?(\[.*?\])\s*`{3}'
|
arr = _extract_json_array(text)
|
||||||
code_match = re.search(code_pat, response, re.DOTALL)
|
if isinstance(arr, list):
|
||||||
if code_match:
|
return arr
|
||||||
try:
|
|
||||||
data = json.loads(code_match.group(1))
|
|
||||||
if isinstance(data, list):
|
|
||||||
return data
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Versuche JSON aus der Antwort zu extrahieren (zwischen [ und ])
|
# 3) JSON-Objekt mit "articles"-Key
|
||||||
arr_pat = r'\[\s*\{.*\}\s*\]'
|
obj = _extract_json_object(text)
|
||||||
match = re.search(arr_pat, response, re.DOTALL)
|
if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
|
||||||
if match:
|
return obj["articles"]
|
||||||
try:
|
|
||||||
data = json.loads(match.group())
|
|
||||||
if isinstance(data, list):
|
|
||||||
return data
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Letzter Versuch: einzelne JSON-Objekte mit headline
|
# 4) Recovery: einzelne Headline-Objekte aus Fliesstext
|
||||||
objects = re.findall(r'\{[^{}]*"headline"[^{}]*\}', response)
|
recovered = []
|
||||||
if objects:
|
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):
|
||||||
results = []
|
|
||||||
for obj_str in objects:
|
|
||||||
try:
|
try:
|
||||||
obj = json.loads(obj_str)
|
parsed = json.loads(obj_str)
|
||||||
if "headline" in obj:
|
|
||||||
results.append(obj)
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
if results:
|
if isinstance(parsed, dict) and "headline" in parsed:
|
||||||
logger.info(f"JSON-Recovery: {len(results)} Artikel aus Einzelobjekten extrahiert")
|
recovered.append(parsed)
|
||||||
return results
|
if recovered:
|
||||||
|
logger.info("JSON-Recovery: %d Artikel aus Einzelobjekten extrahiert", len(recovered))
|
||||||
|
return recovered
|
||||||
|
|
||||||
logger.warning(f"Konnte Claude-Antwort nicht als JSON parsen (Laenge: {len(response)})")
|
# Parse fehlgeschlagen — Claude hat geantwortet, aber kein verwertbares JSON dabei.
|
||||||
return []
|
# 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)})")
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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(
|
async def select_relevant_telegram_channels(
|
||||||
self,
|
self,
|
||||||
@@ -486,12 +647,14 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
arr_match = re.search(r'\[[\d\s,]+\]', result)
|
indices = _extract_json_array(result)
|
||||||
if not arr_match:
|
if not isinstance(indices, list):
|
||||||
logger.warning("Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele")
|
logger.warning(
|
||||||
|
"Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele. Sample: %s",
|
||||||
|
_truncate_for_log(result),
|
||||||
|
)
|
||||||
return channels_metadata, usage
|
return channels_metadata, usage
|
||||||
|
|
||||||
indices = json.loads(arr_match.group())
|
|
||||||
selected = []
|
selected = []
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):
|
if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):
|
||||||
|
|||||||
254
src/agents/translator.py
Normale Datei
254
src/agents/translator.py
Normale Datei
@@ -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
|
||||||
21
src/auth.py
21
src/auth.py
@@ -1,13 +1,12 @@
|
|||||||
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
|
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
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"
|
JWT_ISSUER = "intelsight-osint"
|
||||||
@@ -21,6 +20,7 @@ def create_token(
|
|||||||
role: str = "member",
|
role: str = "member",
|
||||||
tenant_id: int = None,
|
tenant_id: int = None,
|
||||||
org_slug: str = None,
|
org_slug: str = None,
|
||||||
|
is_global_admin: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""JWT-Token erstellen mit Tenant-Kontext."""
|
"""JWT-Token erstellen mit Tenant-Kontext."""
|
||||||
now = datetime.now(TIMEZONE)
|
now = datetime.now(TIMEZONE)
|
||||||
@@ -32,12 +32,13 @@ def create_token(
|
|||||||
"role": role,
|
"role": role,
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"org_slug": org_slug,
|
"org_slug": org_slug,
|
||||||
|
"is_global_admin": is_global_admin,
|
||||||
"iss": JWT_ISSUER,
|
"iss": JWT_ISSUER,
|
||||||
"aud": JWT_AUDIENCE,
|
"aud": JWT_AUDIENCE,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": expire,
|
"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:
|
def decode_token(token: str) -> dict:
|
||||||
@@ -45,7 +46,7 @@ def decode_token(token: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
JWT_SECRET,
|
get_jwt_secret(),
|
||||||
algorithms=[JWT_ALGORITHM],
|
algorithms=[JWT_ALGORITHM],
|
||||||
issuer=JWT_ISSUER,
|
issuer=JWT_ISSUER,
|
||||||
audience=JWT_AUDIENCE,
|
audience=JWT_AUDIENCE,
|
||||||
@@ -62,6 +63,11 @@ async def get_current_user(
|
|||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""FastAPI Dependency: Aktuellen Nutzer aus Token extrahieren."""
|
"""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)
|
payload = decode_token(credentials.credentials)
|
||||||
return {
|
return {
|
||||||
"id": int(payload["sub"]),
|
"id": int(payload["sub"]),
|
||||||
@@ -70,6 +76,7 @@ async def get_current_user(
|
|||||||
"role": payload.get("role", "member"),
|
"role": payload.get("role", "member"),
|
||||||
"tenant_id": payload.get("tenant_id"),
|
"tenant_id": payload.get("tenant_id"),
|
||||||
"org_slug": payload.get("org_slug"),
|
"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."""
|
"""Generiert einen 64-Zeichen URL-safe Token."""
|
||||||
return secrets.token_urlsafe(48)
|
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))
|
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
LOG_DIR = os.path.join(BASE_DIR, "logs")
|
LOG_DIR = os.path.join(BASE_DIR, "logs")
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
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
|
||||||
JWT_SECRET = os.environ.get("JWT_SECRET")
|
_JWT_SECRET = os.environ.get("JWT_SECRET", "")
|
||||||
if not 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")
|
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_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRE_HOURS = 24
|
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_TIMEOUT = 1800 # Sekunden (30 Min - Lage-Updates mit vielen Artikeln brauchen mehr Zeit)
|
||||||
# Claude Modelle
|
# Claude Modelle
|
||||||
CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-Selektion)
|
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)
|
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
|
||||||
OUTPUT_LANGUAGE = "Deutsch"
|
OUTPUT_LANGUAGE = "Deutsch"
|
||||||
@@ -32,6 +41,10 @@ OUTPUT_LANGUAGE = "Deutsch"
|
|||||||
# In Kundenversion auf False setzen oder Env-Variable entfernen
|
# In Kundenversion auf False setzen oder Env-Variable entfernen
|
||||||
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
|
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 (Fallback, primär aus DB geladen)
|
||||||
RSS_FEEDS = {
|
RSS_FEEDS = {
|
||||||
"deutsch": [
|
"deutsch": [
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS incidents (
|
|||||||
type TEXT DEFAULT 'adhoc',
|
type TEXT DEFAULT 'adhoc',
|
||||||
refresh_mode TEXT DEFAULT 'manual',
|
refresh_mode TEXT DEFAULT 'manual',
|
||||||
refresh_interval INTEGER DEFAULT 15,
|
refresh_interval INTEGER DEFAULT 15,
|
||||||
|
refresh_start_time TEXT,
|
||||||
retention_days INTEGER DEFAULT 0,
|
retention_days INTEGER DEFAULT 0,
|
||||||
visibility TEXT DEFAULT 'public',
|
visibility TEXT DEFAULT 'public',
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
@@ -116,6 +117,22 @@ CREATE TABLE IF NOT EXISTS refresh_log (
|
|||||||
tenant_id INTEGER REFERENCES organizations(id)
|
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 (
|
CREATE TABLE IF NOT EXISTS incident_snapshots (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
@@ -362,6 +379,36 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: tenant_id zu incidents hinzugefuegt")
|
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
|
# Migration: Token-Spalten fuer refresh_log
|
||||||
cursor = await db.execute("PRAGMA table_info(refresh_log)")
|
cursor = await db.execute("PRAGMA table_info(refresh_log)")
|
||||||
rl_columns = [row[1] for row in await cursor.fetchall()]
|
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.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
||||||
await db.commit()
|
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)
|
# Migration: notifications-Tabelle (fuer bestehende DBs)
|
||||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
|
||||||
if not await cursor.fetchone():
|
if not await cursor.fetchone():
|
||||||
@@ -552,6 +622,7 @@ async def init_db():
|
|||||||
for idx_sql in [
|
for idx_sql in [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status ON incidents(tenant_id, status)",
|
"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_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:
|
try:
|
||||||
await db.execute(idx_sql)
|
await db.execute(idx_sql)
|
||||||
|
|||||||
@@ -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
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -51,52 +51,5 @@ class RateLimiter:
|
|||||||
self._ip_requests[ip].append(now)
|
self._ip_requests[ip].append(now)
|
||||||
|
|
||||||
|
|
||||||
class VerifyCodeLimiter:
|
# Singleton-Instanz
|
||||||
"""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
|
|
||||||
magic_link_limiter = RateLimiter()
|
magic_link_limiter = RateLimiter()
|
||||||
verify_code_limiter = VerifyCodeLimiter()
|
|
||||||
|
|||||||
@@ -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]:
|
def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
|
||||||
"""Erzeugt Login-E-Mail mit Magic Link und Code.
|
"""Erzeugt Login-E-Mail mit Magic Link.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(subject, html_body)
|
(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 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>
|
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, 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>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin: 0 0 24px 0;">
|
<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>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -39,25 +38,30 @@ def incident_notification_email(
|
|||||||
incident_title: str,
|
incident_title: str,
|
||||||
notifications: list[dict],
|
notifications: list[dict],
|
||||||
dashboard_url: str,
|
dashboard_url: str,
|
||||||
|
incident_type: str = "adhoc",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Erzeugt Benachrichtigungs-E-Mail fuer Lagen-Updates.
|
"""Erzeugt Benachrichtigungs-E-Mail für Lagen-Updates.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Empfaenger-Name
|
username: Empfaenger-Name
|
||||||
incident_title: Titel der Lage/Recherche
|
incident_title: Titel der Lage/Recherche
|
||||||
notifications: Liste von {"text": ..., "icon": ...} Dicts
|
notifications: Liste von {"text": ..., "icon": ...} Dicts
|
||||||
dashboard_url: Link zum Dashboard
|
dashboard_url: Link zum Dashboard
|
||||||
|
incident_type: "adhoc" oder "research"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(subject, html_body)
|
(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}"
|
subject = f"AegisSight - {incident_title}"
|
||||||
|
|
||||||
icon_map = {
|
icon_map = {
|
||||||
"success": "✓", # Haekchen
|
"success": "✓",
|
||||||
"warning": "⚠", # Warndreieck
|
"warning": "⚠",
|
||||||
"error": "✗", # Kreuz
|
"error": "✗",
|
||||||
"info": "ⓘ", # Info-Kreis
|
"info": "ⓘ",
|
||||||
}
|
}
|
||||||
color_map = {
|
color_map = {
|
||||||
"success": "#22c55e",
|
"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;">
|
<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;">
|
<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>
|
<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 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;">
|
<div style="background: #0f172a; border-radius: 8px; padding: 4px 16px; margin: 0 0 24px 0;">
|
||||||
{items_html}
|
{items_html}
|
||||||
|
|||||||
184
src/feeds/podcast_parser.py
Normale Datei
184
src/feeds/podcast_parser.py
Normale Datei
@@ -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
|
||||||
@@ -6,6 +6,8 @@ import httpx
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
||||||
from source_rules import _extract_domain
|
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")
|
logger = logging.getLogger("osint.rss")
|
||||||
|
|
||||||
@@ -152,10 +154,26 @@ class RSSParser:
|
|||||||
|
|
||||||
for entry in feed.entries[:50]:
|
for entry in feed.entries[:50]:
|
||||||
title = entry.get("title", "")
|
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()
|
text = f"{title} {summary}".lower()
|
||||||
|
|
||||||
# Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet)
|
# 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))
|
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
|
||||||
match_count = sum(1 for word in search_words if word in text)
|
match_count = sum(1 for word in search_words if word in text)
|
||||||
|
|
||||||
|
|||||||
121
src/feeds/transcript_extractors/__init__.py
Normale Datei
121
src/feeds/transcript_extractors/__init__.py
Normale Datei
@@ -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()
|
||||||
170
src/feeds/transcript_extractors/_common.py
Normale Datei
170
src/feeds/transcript_extractors/_common.py
Normale Datei
@@ -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(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", '"')
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("–", "-")
|
||||||
|
.replace("—", "-")
|
||||||
|
.replace("ä", "ä")
|
||||||
|
.replace("ö", "ö")
|
||||||
|
.replace("ü", "ü")
|
||||||
|
.replace("Ä", "Ä")
|
||||||
|
.replace("Ö", "Ö")
|
||||||
|
.replace("Ü", "Ü")
|
||||||
|
.replace("ß", "ß"))
|
||||||
|
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
|
||||||
182
src/feeds/transcript_extractors/rss_native.py
Normale Datei
182
src/feeds/transcript_extractors/rss_native.py
Normale Datei
@@ -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(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", '"')
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">"))
|
||||||
|
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
|
||||||
61
src/feeds/transcript_extractors/website_dlf.py
Normale Datei
61
src/feeds/transcript_extractors/website_dlf.py
Normale Datei
@@ -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")
|
||||||
51
src/feeds/transcript_extractors/website_ndr.py
Normale Datei
51
src/feeds/transcript_extractors/website_ndr.py
Normale Datei
@@ -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")
|
||||||
51
src/feeds/transcript_extractors/website_spiegel.py
Normale Datei
51
src/feeds/transcript_extractors/website_spiegel.py
Normale Datei
@@ -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")
|
||||||
53
src/feeds/transcript_extractors/website_sz.py
Normale Datei
53
src/feeds/transcript_extractors/website_sz.py
Normale Datei
@@ -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")
|
||||||
77
src/main.py
77
src/main.py
@@ -5,7 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
|
||||||
@@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler()
|
|||||||
|
|
||||||
|
|
||||||
async def check_auto_refresh():
|
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()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute(
|
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()
|
incidents = await cursor.fetchall()
|
||||||
|
|
||||||
@@ -120,18 +120,72 @@ async def check_auto_refresh():
|
|||||||
for incident in incidents:
|
for incident in incidents:
|
||||||
incident_id = incident["id"]
|
incident_id = incident["id"]
|
||||||
interval = incident["refresh_interval"]
|
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(
|
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,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
last_refresh = await cursor.fetchone()
|
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
|
should_refresh = False
|
||||||
|
|
||||||
if not last_refresh:
|
if not last_refresh:
|
||||||
|
# Noch nie gelaufen -> sofort starten
|
||||||
should_refresh = True
|
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:
|
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"])
|
last_time = datetime.fromisoformat(last_refresh["started_at"])
|
||||||
if last_time.tzinfo is None:
|
if last_time.tzinfo is None:
|
||||||
last_time = last_time.replace(tzinfo=TIMEZONE)
|
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")
|
logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig")
|
||||||
|
|
||||||
if should_refresh:
|
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")
|
await orchestrator.enqueue_refresh(incident_id, trigger_type="auto")
|
||||||
|
|
||||||
except Exception as e:
|
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.feedback import router as feedback_router
|
||||||
from routers.public_api import router as public_api_router
|
from routers.public_api import router as public_api_router
|
||||||
from routers.chat import router as chat_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 routers.tutorial import router as tutorial_router
|
||||||
|
from routes.version_router import router as version_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(incidents_router)
|
app.include_router(incidents_router)
|
||||||
@@ -342,8 +387,8 @@ app.include_router(notifications_router)
|
|||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(public_api_router)
|
app.include_router(public_api_router)
|
||||||
app.include_router(chat_router, prefix="/api/chat")
|
app.include_router(chat_router, prefix="/api/chat")
|
||||||
app.include_router(network_analysis_router)
|
|
||||||
app.include_router(tutorial_router)
|
app.include_router(tutorial_router)
|
||||||
|
app.include_router(version_router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/ws")
|
@app.websocket("/api/ws")
|
||||||
|
|||||||
@@ -40,12 +40,25 @@ async def require_writable_license(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
|
"""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", {})
|
lic = current_user.get("license", {})
|
||||||
if lic.get("read_only"):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.",
|
detail=detail,
|
||||||
|
headers={"X-License-Status": reason},
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ class VerifyTokenRequest(BaseModel):
|
|||||||
token: str
|
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):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
@@ -41,9 +37,12 @@ class UserMeResponse(BaseModel):
|
|||||||
license_status: str = "unknown"
|
license_status: str = "unknown"
|
||||||
license_type: str = ""
|
license_type: str = ""
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
|
read_only_reason: Optional[str] = None
|
||||||
|
unlimited_budget: bool = False
|
||||||
credits_total: Optional[int] = None
|
credits_total: Optional[int] = None
|
||||||
credits_remaining: Optional[int] = None
|
credits_remaining: Optional[int] = None
|
||||||
credits_percent_used: Optional[float] = None
|
credits_percent_used: Optional[float] = None
|
||||||
|
is_global_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Incidents (Lagen)
|
# Incidents (Lagen)
|
||||||
@@ -53,6 +52,7 @@ class IncidentCreate(BaseModel):
|
|||||||
type: str = Field(default="adhoc", pattern="^(adhoc|research)$")
|
type: str = Field(default="adhoc", pattern="^(adhoc|research)$")
|
||||||
refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$")
|
refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$")
|
||||||
refresh_interval: int = Field(default=15, ge=10, le=10080)
|
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)
|
retention_days: int = Field(default=0, ge=0, le=999)
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
@@ -66,13 +66,25 @@ class IncidentUpdate(BaseModel):
|
|||||||
status: Optional[str] = Field(default=None, pattern="^(active|archived)$")
|
status: Optional[str] = Field(default=None, pattern="^(active|archived)$")
|
||||||
refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$")
|
refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$")
|
||||||
refresh_interval: Optional[int] = Field(default=None, ge=10, le=10080)
|
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)
|
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
|
||||||
international_sources: Optional[bool] = None
|
international_sources: Optional[bool] = None
|
||||||
include_telegram: Optional[bool] = None
|
include_telegram: Optional[bool] = None
|
||||||
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
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):
|
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
|
id: int
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
@@ -80,10 +92,11 @@ class IncidentResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
refresh_mode: str
|
refresh_mode: str
|
||||||
refresh_interval: int
|
refresh_interval: int
|
||||||
|
refresh_start_time: Optional[str] = None
|
||||||
retention_days: int
|
retention_days: int
|
||||||
visibility: str = "public"
|
visibility: str = "public"
|
||||||
summary: Optional[str]
|
summary: Optional[str]
|
||||||
sources_json: Optional[str] = None
|
latest_developments: Optional[str] = None
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
@@ -94,6 +107,35 @@ class IncidentResponse(BaseModel):
|
|||||||
source_count: int = 0
|
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)
|
# Sources (Quellenverwaltung)
|
||||||
@@ -101,7 +143,7 @@ class SourceCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
domain: 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)$")
|
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)$")
|
status: str = Field(default="active", pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
@@ -111,7 +153,7 @@ class SourceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(default=None, max_length=200)
|
name: Optional[str] = Field(default=None, max_length=200)
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
domain: 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)$")
|
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)$")
|
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
@@ -201,3 +243,16 @@ class FeedbackRequest(BaseModel):
|
|||||||
message: str = Field(min_length=10, max_length=5000)
|
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
|
||||||
|
|||||||
@@ -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
965
src/report_generator.py
Normale Datei
@@ -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('"', """)
|
||||||
|
name = (src.get("name") or "").replace('"', """)
|
||||||
|
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()
|
||||||
214
src/report_templates/report.html
Normale Datei
214
src/report_templates/report.html
Normale Datei
@@ -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 — aegis-sight.de — {{ report_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
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 (
|
from models import (
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
MagicLinkResponse,
|
MagicLinkResponse,
|
||||||
VerifyTokenRequest,
|
VerifyTokenRequest,
|
||||||
VerifyCodeRequest,
|
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
UserMeResponse,
|
UserMeResponse,
|
||||||
)
|
)
|
||||||
@@ -14,13 +19,12 @@ from auth import (
|
|||||||
create_token,
|
create_token,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
generate_magic_token,
|
generate_magic_token,
|
||||||
generate_magic_code,
|
|
||||||
)
|
)
|
||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
|
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
|
||||||
from email_utils.sender import send_email
|
from email_utils.sender import send_email
|
||||||
from email_utils.templates import magic_link_login_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
|
import aiosqlite
|
||||||
|
|
||||||
logger = logging.getLogger("osint.auth")
|
logger = logging.getLogger("osint.auth")
|
||||||
@@ -34,15 +38,14 @@ async def request_magic_link(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
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()
|
email = data.email.lower().strip()
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
# Rate-Limit pruefen
|
# Rate-Limit prüfen
|
||||||
allowed, reason = magic_link_limiter.check(email, ip)
|
allowed, reason = magic_link_limiter.check(email, ip)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
|
logger.warning(f"Rate-Limit für {email} von {ip}: {reason}")
|
||||||
# Trotzdem 200 zurueckgeben (kein Information-Leak)
|
|
||||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||||
|
|
||||||
# Nutzer suchen
|
# Nutzer suchen
|
||||||
@@ -68,19 +71,18 @@ async def request_magic_link(
|
|||||||
magic_link_limiter.record(email, ip)
|
magic_link_limiter.record(email, ip)
|
||||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||||
|
|
||||||
# Lizenz pruefen
|
# Lizenz prüfen
|
||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
lic = await check_license(db, user["organization_id"])
|
lic = await check_license(db, user["organization_id"])
|
||||||
if lic.get("status") == "org_disabled":
|
if lic.get("status") == "org_disabled":
|
||||||
magic_link_limiter.record(email, ip)
|
magic_link_limiter.record(email, ip)
|
||||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||||
|
|
||||||
# Token + Code generieren
|
# Token generieren
|
||||||
token = generate_magic_token()
|
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')
|
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(
|
await db.execute(
|
||||||
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
||||||
(email,),
|
(email,),
|
||||||
@@ -89,14 +91,14 @@ async def request_magic_link(
|
|||||||
# Neuen Magic Link speichern
|
# Neuen Magic Link speichern
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
|
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
|
||||||
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
|
VALUES (?, ?, '', 'login', ?, ?, ?)""",
|
||||||
(email, token, code, user["id"], expires_at, ip),
|
(email, token, user["id"], expires_at, ip),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# E-Mail senden
|
# E-Mail senden
|
||||||
link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
|
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)
|
await send_email(email, subject, html)
|
||||||
|
|
||||||
magic_link_limiter.record(email, ip)
|
magic_link_limiter.record(email, ip)
|
||||||
@@ -121,9 +123,9 @@ async def verify_magic_link(
|
|||||||
ml = await cursor.fetchone()
|
ml = await cursor.fetchone()
|
||||||
|
|
||||||
if not ml:
|
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)
|
now = datetime.now(TIMEZONE)
|
||||||
expires = datetime.fromisoformat(ml["expires_at"])
|
expires = datetime.fromisoformat(ml["expires_at"])
|
||||||
if expires.tzinfo is None:
|
if expires.tzinfo is None:
|
||||||
@@ -144,6 +146,13 @@ async def verify_magic_link(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
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
|
# JWT erstellen
|
||||||
token = create_token(
|
token = create_token(
|
||||||
user_id=ml["user_id"],
|
user_id=ml["user_id"],
|
||||||
@@ -152,84 +161,7 @@ async def verify_magic_link(
|
|||||||
role=ml["role"],
|
role=ml["role"],
|
||||||
tenant_id=ml["organization_id"],
|
tenant_id=ml["organization_id"],
|
||||||
org_slug=ml["org_slug"],
|
org_slug=ml["org_slug"],
|
||||||
)
|
is_global_admin=_is_global_admin,
|
||||||
|
|
||||||
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"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
@@ -261,10 +193,11 @@ async def get_me(
|
|||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
license_info = await check_license(db, current_user["tenant_id"])
|
license_info = await check_license(db, current_user["tenant_id"])
|
||||||
|
|
||||||
# Credits-Daten laden
|
# Credits-Daten laden (echte Prozente, nicht gekappt)
|
||||||
credits_total = None
|
credits_total = None
|
||||||
credits_remaining = None
|
credits_remaining = None
|
||||||
credits_percent_used = None
|
credits_percent_used = None
|
||||||
|
unlimited_budget = bool(license_info.get("unlimited_budget", False))
|
||||||
if current_user.get("tenant_id"):
|
if current_user.get("tenant_id"):
|
||||||
lic_cursor = await db.execute(
|
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",
|
"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_total = lic_row["credits_total"]
|
||||||
credits_used = lic_row["credits_used"] or 0
|
credits_used = lic_row["credits_used"] or 0
|
||||||
credits_remaining = max(0, int(credits_total - credits_used))
|
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(
|
return UserMeResponse(
|
||||||
id=current_user["id"],
|
id=current_user["id"],
|
||||||
@@ -290,4 +228,65 @@ async def get_me(
|
|||||||
license_status=license_info.get("status", "unknown"),
|
license_status=license_info.get("status", "unknown"),
|
||||||
license_type=license_info.get("license_type", ""),
|
license_type=license_info.get("license_type", ""),
|
||||||
read_only=license_info.get("read_only", False),
|
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"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from config import CLAUDE_PATH, CLAUDE_MODEL_FAST
|
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")
|
logger = logging.getLogger("osint.chat")
|
||||||
|
|
||||||
@@ -21,8 +26,8 @@ router = APIRouter(tags=["chat"])
|
|||||||
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
|
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _call_claude_chat(prompt: str) -> tuple[str, int]:
|
async def _call_claude_chat(prompt: str) -> tuple[str, int, ClaudeUsage]:
|
||||||
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms) zurueck.
|
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms, usage) zurueck.
|
||||||
|
|
||||||
Anders als call_claude(): kein JSON-Output-Modus, kein append-system-prompt.
|
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:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(
|
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:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
process.kill()
|
||||||
@@ -54,29 +59,44 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]:
|
|||||||
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
err_msg = stderr.decode("utf-8", errors="replace").strip()
|
err_msg = stderr.decode("utf-8", errors="replace").strip()
|
||||||
logger.error(f"Chat Claude CLI Fehler (rc={process.returncode}): {err_msg[:500]}")
|
stdout_msg = stdout.decode("utf-8", errors="replace").strip()
|
||||||
if "rate_limit" in err_msg.lower() or "overloaded" in err_msg.lower():
|
combined = f"{err_msg} {stdout_msg}"
|
||||||
raise RuntimeError("rate_limit")
|
error_type = _classify_cli_error(combined)
|
||||||
raise RuntimeError(f"Claude CLI Fehler: {err_msg[:200]}")
|
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()
|
raw = stdout.decode("utf-8", errors="replace").strip()
|
||||||
duration_ms = 0
|
duration_ms = 0
|
||||||
result_text = raw
|
result_text = raw
|
||||||
|
usage = ClaudeUsage()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = _json.loads(raw)
|
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)
|
result_text = data.get("result", raw)
|
||||||
duration_ms = data.get("duration_ms", 0)
|
duration_ms = data.get("duration_ms", 0)
|
||||||
cost = data.get("total_cost_usd", 0.0)
|
|
||||||
u = data.get("usage", {})
|
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(
|
logger.info(
|
||||||
f"Chat Claude: {u.get('input_tokens', 0)} in / {u.get('output_tokens', 0)} out / "
|
f"Chat Claude: {usage.input_tokens} in / {usage.output_tokens} out / "
|
||||||
f"${cost:.4f} / {duration_ms}ms"
|
f"${usage.cost_usd:.4f} / {duration_ms}ms"
|
||||||
)
|
)
|
||||||
except _json.JSONDecodeError:
|
except _json.JSONDecodeError:
|
||||||
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
|
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
|
||||||
|
|
||||||
return result_text, duration_ms
|
return result_text, duration_ms, usage
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Models
|
# 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."
|
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.
|
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:
|
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.
|
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:
|
Typische Fragen die du beantworten kannst:
|
||||||
- Wie erstelle ich eine neue Lage?
|
- 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 funktioniert der automatische Refresh?
|
||||||
- Wie exportiere ich einen Lagebericht?
|
- Wie exportiere ich einen Lagebericht?
|
||||||
- Was bedeuten die Faktencheck-Status?
|
- Was bedeuten die Faktencheck-Status?
|
||||||
@@ -295,7 +318,9 @@ Typische Fragen die du beantworten kannst:
|
|||||||
FEATURE-DOKUMENTATION:
|
FEATURE-DOKUMENTATION:
|
||||||
|
|
||||||
Lage/Recherche erstellen:
|
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:
|
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.
|
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:
|
||||||
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.
|
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:
|
Aktualisierungs-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.
|
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:
|
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:
|
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:
|
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:
|
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.
|
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):
|
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.
|
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):
|
Kartenansicht:
|
||||||
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.
|
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:
|
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.
|
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)
|
@router.post("", response_model=ChatResponse)
|
||||||
async def chat(
|
async def chat(
|
||||||
req: ChatRequest,
|
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."""
|
"""Chat-Nachricht verarbeiten und Antwort generieren."""
|
||||||
user_id = current_user["id"]
|
user_id = current_user["id"]
|
||||||
@@ -415,15 +441,23 @@ async def chat(
|
|||||||
|
|
||||||
# Claude CLI aufrufen
|
# Claude CLI aufrufen
|
||||||
try:
|
try:
|
||||||
result, duration_ms = await _call_claude_chat(prompt)
|
result, duration_ms, usage = await _call_claude_chat(prompt)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
|
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
|
||||||
except RuntimeError as e:
|
except ClaudeCliError as e:
|
||||||
error_str = str(e)
|
if e.error_type == "rate_limit":
|
||||||
if "rate_limit" in error_str:
|
|
||||||
raise HTTPException(status_code=429, detail="Der Assistent ist gerade ausgelastet. Bitte versuche es in einer Minute erneut.")
|
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.")
|
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
|
# Output sanitieren
|
||||||
reply = _sanitize_output(result)
|
reply = _sanitize_output(result)
|
||||||
|
|||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -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"'},
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Öffentliche API für die Lagebild-Seite auf aegissight.de.
|
"""Öffentliche API für die Lagebild-Seite auf aegissight.de.
|
||||||
|
|
||||||
Authentifizierung via X-API-Key Header (getrennt von der JWT-Auth).
|
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 json
|
||||||
import logging
|
import logging
|
||||||
@@ -50,14 +50,23 @@ def _in_clause(ids):
|
|||||||
return ",".join(str(int(i)) for i in 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)):
|
# Shared-Logik für Lagebild-Responses
|
||||||
"""Liefert das aktuelle Lagebild (Irankonflikt) mit allen Daten."""
|
# ──────────────────────────────────────────────────────────────────
|
||||||
ids = _in_clause(IRAN_INCIDENT_IDS)
|
|
||||||
|
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)
|
# Haupt-Incident laden (für Summary, Sources)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT * FROM incidents WHERE id = ?", (PRIMARY_INCIDENT_ID,)
|
"SELECT * FROM incidents WHERE id = ?", (primary_id,)
|
||||||
)
|
)
|
||||||
incident = await cursor.fetchone()
|
incident = await cursor.fetchone()
|
||||||
if not incident:
|
if not incident:
|
||||||
@@ -72,7 +81,7 @@ async def get_lagebild(db=Depends(db_dependency)):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Alle Artikel aus allen Iran-Incidents laden
|
# Alle Artikel laden
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"""SELECT id, headline, headline_de, source, source_url, language,
|
f"""SELECT id, headline, headline_de, source, source_url, language,
|
||||||
published_at, collected_at, verification_status, incident_id
|
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()]
|
articles = [dict(r) for r in await cursor.fetchall()]
|
||||||
|
|
||||||
# Alle Faktenchecks aus allen Iran-Incidents laden
|
# Alle Faktenchecks laden
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"""SELECT id, claim, status, sources_count, evidence, status_history, checked_at, incident_id
|
f"""SELECT id, claim, status, sources_count, evidence, status_history, checked_at, incident_id
|
||||||
FROM fact_checks WHERE incident_id IN ({ids})
|
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"]
|
source_count = (await cursor.fetchone())["cnt"]
|
||||||
|
|
||||||
# Snapshots aus allen Iran-Incidents
|
# Snapshots
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"""SELECT id, incident_id, article_count, fact_check_count, created_at
|
f"""SELECT id, incident_id, article_count, fact_check_count, created_at
|
||||||
FROM incident_snapshots WHERE incident_id IN ({ids})
|
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()]
|
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 {
|
return {
|
||||||
"generated_at": datetime.now(TIMEZONE).isoformat(),
|
"generated_at": datetime.now(TIMEZONE).isoformat(),
|
||||||
"incident": {
|
"incident": {
|
||||||
@@ -146,6 +179,7 @@ async def get_lagebild(db=Depends(db_dependency)):
|
|||||||
"article_count": len(articles),
|
"article_count": len(articles),
|
||||||
"source_count": source_count,
|
"source_count": source_count,
|
||||||
"factcheck_count": len(fact_checks),
|
"factcheck_count": len(fact_checks),
|
||||||
|
"latest_developments": incident.get("latest_developments") or "",
|
||||||
},
|
},
|
||||||
"current_lagebild": {
|
"current_lagebild": {
|
||||||
"summary_markdown": incident.get("summary", ""),
|
"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_response(db, snapshot_id: int, incident_ids: list) -> dict:
|
||||||
async def get_snapshot(snapshot_id: int, db=Depends(db_dependency)):
|
"""Liefert einen historischen Snapshot für die angegebenen Incidents."""
|
||||||
"""Liefert einen historischen Snapshot."""
|
ids = _in_clause(incident_ids)
|
||||||
ids = _in_clause(IRAN_INCIDENT_IDS)
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"""SELECT id, summary, sources_json, article_count, fact_check_count, created_at
|
f"""SELECT id, summary, sources_json, article_count, fact_check_count, created_at
|
||||||
FROM incident_snapshots
|
FROM incident_snapshots
|
||||||
@@ -181,3 +214,233 @@ async def get_snapshot(snapshot_id: int, db=Depends(db_dependency)):
|
|||||||
snap["sources_json"] = []
|
snap["sources_json"] = []
|
||||||
|
|
||||||
return snap
|
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)
|
||||||
|
|||||||
@@ -415,12 +415,14 @@ async def create_source(
|
|||||||
"""Neue Quelle hinzufuegen (org-spezifisch)."""
|
"""Neue Quelle hinzufuegen (org-spezifisch)."""
|
||||||
tenant_id = current_user.get("tenant_id")
|
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
|
domain = data.domain
|
||||||
|
if not domain and data.url:
|
||||||
|
domain = _extract_domain(data.url)
|
||||||
if domain:
|
if domain:
|
||||||
domain = _DOMAIN_ALIASES.get(domain.lower(), domain.lower())
|
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:
|
if data.url:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id, name FROM sources WHERE url = ? AND status = 'active'",
|
"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']})",
|
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(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
|||||||
0
src/routes/__init__.py
Normale Datei
0
src/routes/__init__.py
Normale Datei
54
src/routes/version_router.py
Normale Datei
54
src/routes/version_router.py
Normale Datei
@@ -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}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Lizenz-Verwaltung und -Pruefung."""
|
"""Lizenz-Verwaltung und -Pruefung."""
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@@ -7,11 +8,21 @@ import aiosqlite
|
|||||||
logger = logging.getLogger("osint.license")
|
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:
|
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
||||||
"""Prueft den Lizenzstatus einer Organisation.
|
"""Prueft den Lizenzstatus einer Organisation.
|
||||||
|
|
||||||
Returns:
|
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
|
# Organisation pruefen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -20,10 +31,14 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
org = await cursor.fetchone()
|
org = await cursor.fetchone()
|
||||||
if not org:
|
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"]:
|
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
|
# Aktive Lizenz suchen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -35,7 +50,19 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
license_row = await cursor.fetchone()
|
license_row = await cursor.fetchone()
|
||||||
|
|
||||||
if not license_row:
|
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
|
# Ablauf pruefen
|
||||||
now = datetime.now(TIMEZONE)
|
now = datetime.now(TIMEZONE)
|
||||||
@@ -52,11 +79,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"status": "expired",
|
"status": "expired",
|
||||||
"license_type": license_row["license_type"],
|
"license_type": license_row["license_type"],
|
||||||
"read_only": True,
|
"read_only": True,
|
||||||
|
"read_only_reason": "expired",
|
||||||
"message": "Lizenz abgelaufen",
|
"message": "Lizenz abgelaufen",
|
||||||
|
"unlimited_budget": unlimited_budget,
|
||||||
|
"credits_total": credits_total,
|
||||||
|
"credits_used": credits_used,
|
||||||
}
|
}
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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
|
# Nutzerzahl pruefen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
|
"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"]
|
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 {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"status": license_row["status"],
|
"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"],
|
"max_users": license_row["max_users"],
|
||||||
"current_users": current_users,
|
"current_users": current_users,
|
||||||
"read_only": False,
|
"read_only": False,
|
||||||
|
"read_only_reason": None,
|
||||||
"message": "Lizenz aktiv",
|
"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, ""
|
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):
|
async def expire_licenses(db: aiosqlite.Connection):
|
||||||
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
|
|||||||
230
src/services/pipeline_tracker.py
Normale Datei
230
src/services/pipeline_tracker.py
Normale Datei
@@ -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)
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
Prueft nach jedem Refresh:
|
Prueft nach jedem Refresh:
|
||||||
1. Semantische Faktencheck-Duplikate (Haiku-Clustering mit Fuzzy-Vorfilter)
|
1. Semantische Faktencheck-Duplikate (Haiku-Clustering mit Fuzzy-Vorfilter)
|
||||||
2. Falsch kategorisierte Karten-Locations (Haiku bewertet Kontext der Lage)
|
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.
|
Regelbasierte Listen dienen als Fallback falls Haiku fehlschlaegt.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from difflib import SequenceMatcher
|
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(
|
locations_fixed = await check_location_categories(
|
||||||
db, incident_id, incident_title, incident_desc
|
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()
|
await db.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert",
|
"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,
|
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:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Post-Refresh QC Fehler fuer Incident %d: %s",
|
"Post-Refresh QC Fehler fuer Incident %d: %s",
|
||||||
incident_id, e, exc_info=True,
|
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
|
||||||
|
|||||||
1
src/services/umlaut_dict.json
Normale Datei
1
src/services/umlaut_dict.json
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
@@ -84,6 +84,8 @@ DOMAIN_CATEGORY_MAP = {
|
|||||||
"ksta.de": "regional",
|
"ksta.de": "regional",
|
||||||
"rp-online.de": "regional",
|
"rp-online.de": "regional",
|
||||||
"merkur.de": "regional",
|
"merkur.de": "regional",
|
||||||
|
# Telegram
|
||||||
|
"t.me": "telegram",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bekannte Feed-Pfade zum Durchprobieren
|
# 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]:
|
async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss_feed") -> list[dict]:
|
||||||
"""Alle aktiven RSS-Feeds mit Metadaten fuer Claude-Selektion (global + org-spezifisch)."""
|
"""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
|
from database import get_db
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = 'rss_feed' AND status = 'active' "
|
"WHERE source_type = ? AND status = 'active' "
|
||||||
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
(tenant_id,),
|
(source_type, tenant_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = 'rss_feed' AND status = 'active'"
|
"WHERE source_type = ? AND status = 'active'",
|
||||||
|
(source_type,),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
except Exception as e:
|
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 []
|
return []
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|||||||
@@ -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
@@ -4,20 +4,25 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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/svg+xml" href="/static/favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
|
||||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
|
||||||
<title>AegisSight Monitor</title>
|
<title>AegisSight Monitor</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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://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/leaflet.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.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=20260501h">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
<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-label">Organisation</span>
|
||||||
<span class="header-dropdown-value" id="header-org-name">-</span>
|
<span class="header-dropdown-value" id="header-org-name">-</span>
|
||||||
</div>
|
</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">
|
<div class="header-dropdown-row">
|
||||||
<span class="header-dropdown-label">Lizenz</span>
|
<span class="header-dropdown-label">Lizenz</span>
|
||||||
<span class="header-dropdown-value" id="header-license-info">-</span>
|
<span class="header-dropdown-value" id="header-license-info">-</span>
|
||||||
@@ -57,9 +68,15 @@
|
|||||||
<div id="credits-bar" class="credits-bar"></div>
|
<div id="credits-bar" class="credits-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="credits-info">
|
<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>
|
</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>
|
</div>
|
||||||
<div class="header-license-warning" id="header-license-warning"></div>
|
<div class="header-license-warning" id="header-license-warning"></div>
|
||||||
@@ -70,8 +87,7 @@
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<nav class="sidebar" aria-label="Seitenleiste">
|
<nav class="sidebar" aria-label="Seitenleiste">
|
||||||
<div class="sidebar-section">
|
<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-incident-btn" style="margin-bottom:6px;">+ Neuer Fall</button>
|
||||||
<!-- <button class="btn btn-primary btn-full btn-small" id="new-network-btn" onclick="App.openNetworkModal()">+ Neue Netzwerkanalyse</button> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-filter">
|
<div class="sidebar-filter">
|
||||||
@@ -91,22 +107,13 @@
|
|||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0" aria-expanded="true">
|
<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">▾</span>
|
<span class="sidebar-chevron" id="chevron-active-research" aria-hidden="true">▾</span>
|
||||||
Deep-Research
|
Recherchen
|
||||||
<span class="sidebar-section-count" id="count-active-research"></span>
|
<span class="sidebar-section-count" id="count-active-research"></span>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="active-research" aria-live="polite"></div>
|
<div id="active-research" aria-live="polite"></div>
|
||||||
</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">▾</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">
|
<div class="sidebar-section">
|
||||||
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0" aria-expanded="false">
|
<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">▾</span>
|
<span class="sidebar-chevron" id="chevron-archived-incidents" aria-hidden="true">▾</span>
|
||||||
@@ -116,9 +123,17 @@
|
|||||||
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-sources-link">
|
<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" onclick="App.openSourceManagement()" title="Quellen verwalten">
|
||||||
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
|
<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>
|
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
|
||||||
|
-->
|
||||||
<div class="sidebar-stats-mini">
|
<div class="sidebar-stats-mini">
|
||||||
<span id="stat-sources-count">0 Quellen</span> · <span id="stat-articles-count">0 Artikel</span>
|
<span id="stat-sources-count">0 Quellen</span> · <span id="stat-articles-count">0 Artikel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,101 +145,12 @@
|
|||||||
<div class="empty-state" id="empty-state">
|
<div class="empty-state" id="empty-state">
|
||||||
<div class="empty-state-icon">☉</div>
|
<div class="empty-state-icon">☉</div>
|
||||||
<div class="empty-state-title">Kein Vorfall ausgewählt</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Netzwerkanalyse View (hidden by default) -->
|
<!-- 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 ▾</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">☉</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) -->
|
<!-- Lagebild (hidden by default) -->
|
||||||
<div id="incident-view" style="display:none;">
|
<div id="incident-view" style="display:none;">
|
||||||
@@ -241,18 +167,7 @@
|
|||||||
<div class="incident-header-actions">
|
<div class="incident-header-actions">
|
||||||
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
|
<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>
|
<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.openExportModal()">Bericht exportieren</button>
|
||||||
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)" aria-expanded="false" aria-haspopup="true">Exportieren ▾</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" id="archive-incident-btn">Archivieren</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>
|
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,51 +195,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fortschrittsanzeige -->
|
<!-- Minimierte 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-mini" id="progress-mini" style="display:none;" onclick="App.openProgressPopup()">
|
||||||
<div class="progress-steps">
|
<span class="progress-mini-dot"></span>
|
||||||
<div class="progress-step" id="step-researching">
|
<span class="progress-mini-text" id="progress-mini-text">Läuft...</span>
|
||||||
<div class="progress-step-dot"></div>
|
<span class="progress-mini-timer" id="progress-mini-timer"></span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layout-Toolbar -->
|
<!-- Tab-Navigation -->
|
||||||
<div class="layout-toolbar" id="layout-toolbar" style="display:none;">
|
<div class="tab-nav" id="tab-nav" style="display:none;">
|
||||||
<div class="layout-toggles">
|
<button class="tab-btn active" data-tab="zusammenfassung">Neueste Entwicklungen</button>
|
||||||
<button class="layout-toggle-btn active" data-tile="lagebild" onclick="LayoutManager.toggleTile('lagebild')" aria-pressed="true">Lagebild</button>
|
<button class="tab-btn" data-tab="lagebild">Lagebild</button>
|
||||||
<button class="layout-toggle-btn active" data-tile="faktencheck" onclick="LayoutManager.toggleTile('faktencheck')" aria-pressed="true">Faktencheck</button>
|
<button class="tab-btn" data-tab="timeline">Ereignis-Timeline</button>
|
||||||
<button class="layout-toggle-btn active" data-tile="quellen" onclick="LayoutManager.toggleTile('quellen')" aria-pressed="true">Quellen</button>
|
<button class="tab-btn" data-tab="karte">Geografische Verteilung</button>
|
||||||
<button class="layout-toggle-btn active" data-tile="timeline" onclick="LayoutManager.toggleTile('timeline')" aria-pressed="true">Timeline</button>
|
<button class="tab-btn" data-tab="faktencheck">Faktencheck</button>
|
||||||
<button class="layout-toggle-btn active" data-tile="karte" onclick="LayoutManager.toggleTile('karte')" aria-pressed="true">Karte</button>
|
<button class="tab-btn" data-tab="pipeline">Analysepipeline</button>
|
||||||
</div>
|
<button class="tab-btn" data-tab="quellen">Quellenübersicht</button>
|
||||||
<button class="btn btn-secondary btn-small" onclick="LayoutManager.reset()">Layout zurücksetzen</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- gridstack Dashboard-Grid -->
|
<!-- Tab-Panels -->
|
||||||
<div class="grid-stack">
|
<div class="tab-panels">
|
||||||
<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="tab-panel active" id="panel-zusammenfassung">
|
||||||
<div class="grid-stack-item-content">
|
<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="tab-panel" id="panel-lagebild">
|
||||||
<div class="card incident-analysis-summary">
|
<div class="card incident-analysis-summary">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
|
<div class="card-title">Lagebild</div>
|
||||||
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
|
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="summary-content">
|
<div id="summary-content">
|
||||||
@@ -332,45 +237,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="tab-panel" id="panel-timeline">
|
||||||
<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. Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert. 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>
|
|
||||||
|
|
||||||
<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">▸</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 timeline-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
|
<div class="card-title">Ereignis-Timeline</div>
|
||||||
<div class="ht-controls">
|
<div class="ht-controls">
|
||||||
<div class="ht-filter-group">
|
<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 active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
|
||||||
@@ -392,19 +263,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="tab-panel" id="panel-karte">
|
||||||
<div class="grid-stack-item-content">
|
|
||||||
<div class="card map-card">
|
<div class="card map-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title">Geografische Verteilung</div>
|
<div class="card-title">Geografische Verteilung</div>
|
||||||
<span class="map-stats" id="map-stats"></span>
|
<span class="map-stats" id="map-stats"></span>
|
||||||
<div class="card-header-actions">
|
<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" 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>
|
</div>
|
||||||
<div class="map-container" id="map-container">
|
<div class="map-container" id="map-container">
|
||||||
@@ -412,11 +278,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</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. Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert. 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>
|
</div>
|
||||||
|
|
||||||
<!-- Parkplatz für ausgeblendete Kacheln -->
|
<div class="tab-panel" id="panel-pipeline">
|
||||||
<div id="tile-parking" style="display:none;"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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-overlay" id="modal-new" role="dialog" aria-modal="true" aria-labelledby="modal-new-title">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<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">×</button>
|
<button class="modal-close" onclick="closeModal('modal-new')" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="new-incident-form">
|
<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">
|
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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>
|
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inc-type">Art der Lage</label>
|
<label for="inc-type">Art der Lage</label>
|
||||||
<select id="inc-type" onchange="toggleTypeDefaults()">
|
<select id="inc-type" onchange="toggleTypeDefaults()">
|
||||||
<option value="adhoc">Live-Monitoring — Ereignis beobachten</option>
|
<option value="adhoc">Live-Monitoring : Ereignis beobachten</option>
|
||||||
<option value="research">Analyse — Thema recherchieren</option>
|
<option value="research">Recherche : Thema analysieren</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-hint" id="type-hint">
|
<div class="form-hint" id="type-hint">
|
||||||
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
|
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
|
||||||
@@ -470,7 +380,7 @@
|
|||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" id="inc-visibility" checked>
|
<input type="checkbox" id="inc-visibility" checked>
|
||||||
<span class="toggle-switch"></span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,6 +403,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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">
|
<input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt">
|
||||||
@@ -726,34 +640,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- Modal: Neue Netzwerkanalyse -->
|
<!-- 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">×</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 -->
|
<!-- Tutorial -->
|
||||||
<div class="tutorial-overlay" id="tutorial-overlay">
|
<div class="tutorial-overlay" id="tutorial-overlay">
|
||||||
<div class="tutorial-spotlight" id="tutorial-spotlight"></div>
|
<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>
|
<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/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.js"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster.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/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/layout.js?v=20260316b"></script>
|
||||||
<script src="/static/js/app.js?v=20260316b"></script>
|
<script src="/static/js/pipeline.js?v=20260501i"></script>
|
||||||
<script src="/static/js/api_network.js?v=20260316a"></script>
|
<script src="/static/js/app.js?v=20260501h"></script>
|
||||||
<script src="/static/js/network-graph.js?v=20260316a"></script>
|
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/app_network.js?v=20260316a"></script>
|
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></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>
|
<script>document.addEventListener("DOMContentLoaded",function(){Chat.init();Tutorial.init()});</script>
|
||||||
|
|
||||||
<!-- Map Fullscreen Overlay -->
|
<!-- Map Fullscreen Overlay -->
|
||||||
@@ -792,28 +676,79 @@
|
|||||||
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
||||||
</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" style="max-width:420px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="pdf-export-title">PDF exportieren</h3>
|
<h3>Bericht exportieren</h3>
|
||||||
<button class="modal-close" onclick="closeModal('modal-pdf-export')" aria-label="Schliessen">×</button>
|
<button class="modal-close" onclick="closeModal('modal-export')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:20px;">
|
<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 style="margin-bottom:16px;">
|
||||||
<div id="pdf-export-tiles" style="display:flex;flex-direction:column;gap:10px;">
|
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Bereiche</label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="lagebild" checked><span>Lagebild</span></label>
|
<label class="export-radio"><input type="checkbox" name="export-section" value="zusammenfassung" checked><span>Zusammenfassung</span></label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="quellen" checked><span>Quellenübersicht</span></label>
|
<label class="export-radio"><input type="checkbox" name="export-section" value="bericht" checked><span>Recherchebericht / Lagebild</span></label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="faktencheck" checked><span>Faktencheck</span></label>
|
<label class="export-radio"><input type="checkbox" name="export-section" value="faktencheck" checked><span>Faktencheck</span></label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="timeline"><span>Ereignis-Timeline</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>
|
</div>
|
||||||
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
<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-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
|
||||||
<button class="btn btn-primary" onclick="App.executePdfExport()">Exportieren</button>
|
<button class="btn btn-primary" id="export-submit-btn" onclick="App.submitExport()">Exportieren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">−</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
8
src/static/favicon.svg
Normale Datei
8
src/static/favicon.svg
Normale Datei
@@ -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 |
@@ -4,10 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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/svg+xml" href="/static/favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
|
||||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
|
||||||
<title>AegisSight Monitor - Login</title>
|
<title>AegisSight Monitor - Login</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Schritt 2: Code eingeben -->
|
<!-- Schritt 2: Link gesendet -->
|
||||||
<form id="code-form" style="display:none;">
|
<div id="link-sent" style="display:none;">
|
||||||
<p style="color: var(--text-secondary); margin: 0 0 16px 0; font-size: 14px;">
|
<div style="text-align:center; padding: 20px 0;">
|
||||||
Ein 6-stelliger Code wurde an <strong id="sent-email"></strong> gesendet.
|
<div style="font-size: 40px; margin-bottom: 16px;">✉</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>
|
</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;">
|
|
||||||
</div>
|
</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">Andere E-Mail verwenden</button>
|
||||||
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurück</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style="text-align:center;margin-top:16px;">
|
<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">☼</button>
|
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">☼</button>
|
||||||
@@ -148,11 +146,10 @@
|
|||||||
throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zu Code-Eingabe wechseln
|
// Link-gesendet-Hinweis anzeigen
|
||||||
document.getElementById('email-form').style.display = 'none';
|
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('sent-email').textContent = currentEmail;
|
||||||
document.getElementById('code').focus();
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
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
|
// Zurück-Button
|
||||||
document.getElementById('back-btn').addEventListener('click', () => {
|
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('email-form').style.display = 'block';
|
||||||
document.getElementById('login-error').style.display = 'none';
|
document.getElementById('login-error').style.display = 'none';
|
||||||
document.getElementById('code').value = '';
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
195
src/static/js/ai-disclaimer.js
Normale Datei
195
src/static/js/ai-disclaimer.js
Normale Datei
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* API-Client für den OSINT Lagemonitor.
|
* 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 = {
|
const API = {
|
||||||
baseUrl: '/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 controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
// Externen Abort weiterleiten an internen Controller
|
||||||
|
if (externalSignal) {
|
||||||
|
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
method,
|
method,
|
||||||
headers: this._getHeaders(),
|
headers: this._getHeaders(),
|
||||||
@@ -52,7 +67,30 @@ const API = {
|
|||||||
} else if (typeof detail === 'object' && detail !== null) {
|
} else if (typeof detail === 'object' && detail !== null) {
|
||||||
detail = JSON.stringify(detail);
|
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;
|
if (response.status === 204) return null;
|
||||||
@@ -70,6 +108,10 @@ const API = {
|
|||||||
return this._request('GET', `/incidents${query}`);
|
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) {
|
createIncident(data) {
|
||||||
return this._request('POST', '/incidents', data);
|
return this._request('POST', '/incidents', data);
|
||||||
},
|
},
|
||||||
@@ -82,6 +124,10 @@ const API = {
|
|||||||
return this._request('GET', `/incidents/${id}`);
|
return this._request('GET', `/incidents/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getIncidentSources(id) {
|
||||||
|
return this._request('GET', `/incidents/${id}/sources`);
|
||||||
|
},
|
||||||
|
|
||||||
updateIncident(id, data) {
|
updateIncident(id, data) {
|
||||||
return this._request('PUT', `/incidents/${id}`, data);
|
return this._request('PUT', `/incidents/${id}`, data);
|
||||||
},
|
},
|
||||||
@@ -90,18 +136,42 @@ const API = {
|
|||||||
return this._request('DELETE', `/incidents/${id}`);
|
return this._request('DELETE', `/incidents/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getArticles(incidentId) {
|
getArticles(incidentId, { limit = 500, offset = 0, search = null } = {}) {
|
||||||
return this._request('GET', `/incidents/${incidentId}/articles`);
|
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) {
|
getFactChecks(incidentId) {
|
||||||
return this._request('GET', `/incidents/${incidentId}/factchecks`);
|
return this._request('GET', `/incidents/${incidentId}/factchecks`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPipeline(incidentId) {
|
||||||
|
return this._request('GET', `/incidents/${incidentId}/pipeline`);
|
||||||
|
},
|
||||||
|
|
||||||
getSnapshots(incidentId) {
|
getSnapshots(incidentId) {
|
||||||
return this._request('GET', `/incidents/${incidentId}/snapshots`);
|
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) {
|
getLocations(incidentId) {
|
||||||
return this._request('GET', `/incidents/${incidentId}/locations`);
|
return this._request('GET', `/incidents/${incidentId}/locations`);
|
||||||
},
|
},
|
||||||
@@ -228,10 +298,25 @@ const API = {
|
|||||||
resetTutorialState() {
|
resetTutorialState() {
|
||||||
return this._request('DELETE', '/tutorial/state');
|
return this._request('DELETE', '/tutorial/state');
|
||||||
},
|
},
|
||||||
exportIncident(id, format, scope) {
|
exportReport(id, format, scope, sections) {
|
||||||
const token = localStorage.getItem('osint_token');
|
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 += `§ions=${sections.join(',')}`;
|
||||||
|
} else if (scope) {
|
||||||
|
url += `&scope=${scope}`;
|
||||||
|
}
|
||||||
|
return fetch(url, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
1515
src/static/js/app.js
1515
src/static/js/app.js
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -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">⚠</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">⚠</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;
|
|
||||||
}
|
|
||||||
@@ -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.');
|
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)
|
// Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
|
||||||
if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
|
// if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
|
||||||
var oldHint = document.getElementById('chat-tutorial-hint');
|
// var oldHint = document.getElementById('chat-tutorial-hint');
|
||||||
if (oldHint) oldHint.remove();
|
// if (oldHint) oldHint.remove();
|
||||||
this._showTutorialHint();
|
// this._showTutorialHint();
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Focus auf Input
|
// Focus auf Input
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -137,15 +137,15 @@ const Chat = {
|
|||||||
this._showTyping();
|
this._showTyping();
|
||||||
this._isLoading = true;
|
this._isLoading = true;
|
||||||
|
|
||||||
// Tutorial-Keywords abfangen
|
// Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
|
||||||
var lowerText = text.toLowerCase();
|
// var lowerText = text.toLowerCase();
|
||||||
if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
|
// if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
|
||||||
this._hideTyping();
|
// this._hideTyping();
|
||||||
this._isLoading = false;
|
// this._isLoading = false;
|
||||||
this.close();
|
// this.close();
|
||||||
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
// if (typeof Tutorial !== 'undefined') Tutorial.start();
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|||||||
@@ -40,12 +40,32 @@ const UI = {
|
|||||||
const activeClass = isActive ? 'active' : '';
|
const activeClass = isActive ? 'active' : '';
|
||||||
const creator = (incident.created_by_username || '').split('@')[0];
|
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 `
|
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>
|
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
|
||||||
<div style="flex:1;min-width:0;">
|
<div style="flex:1;min-width:0;">
|
||||||
<div class="incident-name">${this.escape(incident.title)}</div>
|
<div class="incident-name">${this.escape(incident.title)}</div>
|
||||||
<div class="incident-meta">${incident.article_count} Artikel · ${this.escape(creator)}</div>
|
<div class="incident-meta">${incident.article_count} Artikel · ${this.escape(creator)}</div>
|
||||||
|
${refreshStatusHtml}
|
||||||
</div>
|
</div>
|
||||||
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
|
${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">↻</span>' : ''}
|
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">↻</span>' : ''}
|
||||||
@@ -232,200 +252,659 @@ const UI = {
|
|||||||
/**
|
/**
|
||||||
* Fortschrittsanzeige einblenden und Status setzen.
|
* Fortschrittsanzeige einblenden und Status setzen.
|
||||||
*/
|
*/
|
||||||
showProgress(status, extra = {}) {
|
// === Progress State (per-incident) ===
|
||||||
const bar = document.getElementById('progress-bar');
|
_progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
|
||||||
if (!bar) return;
|
_progressTimerInterval: null,
|
||||||
bar.style.display = 'block';
|
|
||||||
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
|
|
||||||
|
|
||||||
const steps = {
|
_getStepOrder() {
|
||||||
queued: { active: 0, label: 'In Warteschlange...' },
|
return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
|
||||||
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 = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
_getStepLabel(step) {
|
||||||
* Timer-Intervall starten (1x pro Sekunde).
|
const map = {
|
||||||
*/
|
queued: 'In Warteschlange',
|
||||||
_startProgressTimer() {
|
researching: 'Recherchiert...',
|
||||||
if (this._progressTimer) return;
|
deep_researching: 'Tiefenrecherche...',
|
||||||
const timerEl = document.getElementById('progress-timer');
|
analyzing: 'Analysiert...',
|
||||||
if (!timerEl) return;
|
factchecking: 'Faktencheck...',
|
||||||
|
cancelling: 'Wird abgebrochen...',
|
||||||
|
};
|
||||||
|
return map[step] || step;
|
||||||
|
},
|
||||||
|
|
||||||
this._progressTimer = setInterval(() => {
|
showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
|
||||||
if (!this._progressStartTime) return;
|
if (!incidentId) incidentId = App.currentIncidentId;
|
||||||
const elapsed = Math.max(0, Math.floor((Date.now() - this._progressStartTime) / 1000));
|
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 mins = Math.floor(elapsed / 60);
|
||||||
const secs = elapsed % 60;
|
const secs = elapsed % 60;
|
||||||
timerEl.textContent = `${mins}:${String(secs).padStart(2, '0')}`;
|
totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
|
||||||
}, 1000);
|
}
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
if (incidentId === App.currentIncidentId) {
|
||||||
* Abschluss-Animation: Grüner Balken mit Summary-Text.
|
// Remove blur
|
||||||
*/
|
const blurTarget = document.getElementById('incident-view');
|
||||||
showProgressComplete(data) {
|
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
|
||||||
const bar = document.getElementById('progress-bar');
|
|
||||||
if (!bar) return;
|
|
||||||
|
|
||||||
// Timer stoppen
|
const overlay = document.getElementById('progress-overlay');
|
||||||
this._stopProgressTimer();
|
if (overlay) {
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.classList.remove('blocking');
|
||||||
|
}
|
||||||
|
|
||||||
// Alle Steps auf done
|
// Mark all steps done
|
||||||
['step-researching', 'step-analyzing', 'step-factchecking'].forEach(id => {
|
document.querySelectorAll('.progress-check-item').forEach(item => {
|
||||||
const el = document.getElementById(id);
|
item.classList.remove('active', 'error');
|
||||||
if (el) { el.className = 'progress-step done'; }
|
item.classList.add('done');
|
||||||
|
const icon = item.querySelector('.progress-check-icon');
|
||||||
|
if (icon) icon.innerHTML = '\u2713';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fill auf 100%
|
// Show summary
|
||||||
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 = [];
|
const parts = [];
|
||||||
if (data.new_articles > 0) {
|
if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
|
||||||
parts.push(`${data.new_articles} neue Artikel`);
|
if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
|
||||||
}
|
if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
|
||||||
if (data.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 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 summaryEl = document.getElementById('progress-complete-summary');
|
||||||
const cancelBtn = document.getElementById('progress-cancel-btn');
|
if (summaryEl) {
|
||||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
|
||||||
|
+ (totalTimeStr ? '<span class="total-time">Gesamtzeit: ' + totalTimeStr + '</span>' : '');
|
||||||
bar.setAttribute('aria-valuenow', '100');
|
summaryEl.style.display = 'block';
|
||||||
bar.setAttribute('aria-valuetext', 'Abgeschlossen');
|
}
|
||||||
},
|
|
||||||
|
// Update title
|
||||||
/**
|
const titleEl = document.getElementById('progress-popup-title');
|
||||||
* Fehler-Zustand: Roter Balken mit Fehlermeldung.
|
if (titleEl) titleEl.textContent = 'Abgeschlossen';
|
||||||
*/
|
|
||||||
showProgressError(errorMsg, willRetry = false, delay = 0) {
|
// Hide cancel, show minimize
|
||||||
const bar = document.getElementById('progress-bar');
|
const cancelBtn = document.getElementById('progress-cancel-btn');
|
||||||
if (!bar) return;
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||||
bar.style.display = 'block';
|
const minBtn = document.getElementById('progress-popup-minimize');
|
||||||
|
if (minBtn) minBtn.style.display = '';
|
||||||
// Timer stoppen
|
|
||||||
this._stopProgressTimer();
|
// Hide mini bar
|
||||||
|
const mini = document.getElementById('progress-mini');
|
||||||
// Error-Klasse
|
if (mini) mini.style.display = 'none';
|
||||||
bar.classList.remove('progress-bar--complete');
|
}
|
||||||
bar.classList.add('progress-bar--error');
|
|
||||||
|
// Remove sidebar refresh status
|
||||||
const label = document.getElementById('progress-label');
|
this._removeSidebarRefreshStatus(incidentId);
|
||||||
if (label) {
|
|
||||||
label.textContent = willRetry
|
// Clean up state after delay
|
||||||
? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...`
|
setTimeout(() => {
|
||||||
: `Fehlgeschlagen: ${errorMsg}`;
|
this.hideProgress(incidentId);
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
|
||||||
|
if (!incidentId) incidentId = App.currentIncidentId;
|
||||||
|
if (incidentId !== App.currentIncidentId) return;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('progress-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'flex';
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel-Button ausblenden
|
|
||||||
const cancelBtn = document.getElementById('progress-cancel-btn');
|
const cancelBtn = document.getElementById('progress-cancel-btn');
|
||||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||||
|
|
||||||
// Bei finalem Fehler nach 6s ausblenden
|
|
||||||
if (!willRetry) {
|
if (!willRetry) {
|
||||||
setTimeout(() => this.hideProgress(), 6000);
|
this._removeSidebarRefreshStatus(incidentId);
|
||||||
|
setTimeout(() => this.hideProgress(incidentId), 6000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
hideProgress(incidentId) {
|
||||||
* Timer-Intervall stoppen und zurücksetzen.
|
if (!incidentId) incidentId = App.currentIncidentId;
|
||||||
*/
|
|
||||||
_stopProgressTimer() {
|
// Remove blur
|
||||||
if (this._progressTimer) {
|
const blurTarget = document.getElementById('incident-view');
|
||||||
clearInterval(this._progressTimer);
|
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
|
||||||
this._progressTimer = null;
|
|
||||||
|
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 = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
_tickProgressTimers() {
|
||||||
* Fortschrittsanzeige ausblenden.
|
for (const [id, state] of Object.entries(this._progressState)) {
|
||||||
*/
|
if (!state.startTime) continue;
|
||||||
hideProgress() {
|
const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
|
||||||
const bar = document.getElementById('progress-bar');
|
const mins = Math.floor(elapsed / 60);
|
||||||
if (bar) {
|
const secs = elapsed % 60;
|
||||||
bar.style.display = 'none';
|
const timeStr = mins + ':' + String(secs).padStart(2, '0');
|
||||||
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
this._stopProgressTimer();
|
|
||||||
|
// Update sidebar timer for this incident
|
||||||
|
const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
|
||||||
|
if (sidebarTimer) sidebarTimer.textContent = timeStr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 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.
|
* 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) {
|
renderSummary(summary, sourcesJson, incidentType) {
|
||||||
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
|
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
|
||||||
|
|
||||||
let sources = [];
|
const sources = this._parseSources(sourcesJson);
|
||||||
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
|
|
||||||
|
|
||||||
// Markdown-Rendering
|
// Markdown-Rendering
|
||||||
let html = this.escape(summary);
|
let html = this.escape(summary);
|
||||||
@@ -444,10 +923,34 @@ const UI = {
|
|||||||
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
|
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
|
||||||
html = html.replace(/(<br>){2,}/g, '<br>');
|
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) {
|
if (sources.length > 0) {
|
||||||
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
|
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) {
|
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 `<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.
|
* 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) {
|
renderSourceOverview(articles) {
|
||||||
if (!articles || articles.length === 0) return '';
|
if (!articles || articles.length === 0) return '';
|
||||||
|
|
||||||
@@ -523,6 +1058,7 @@ const UI = {
|
|||||||
'international': 'Intl.',
|
'international': 'Intl.',
|
||||||
'regional': 'Regional',
|
'regional': 'Regional',
|
||||||
'boulevard': 'Boulevard',
|
'boulevard': 'Boulevard',
|
||||||
|
'telegram': 'Telegram',
|
||||||
'sonstige': 'Sonstige',
|
'sonstige': 'Sonstige',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
|
* LayoutManager: Tab-Navigation fuer das Monitor-Dashboard.
|
||||||
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
|
* Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage.
|
||||||
*/
|
*/
|
||||||
const LayoutManager = {
|
const LayoutManager = {
|
||||||
_grid: null,
|
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'pipeline', 'quellen'],
|
||||||
_storageKey: 'osint_layout',
|
_currentIncidentId: null,
|
||||||
_initialized: false,
|
_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() {
|
init() {
|
||||||
if (this._initialized) return;
|
if (this._initialized) return;
|
||||||
|
const nav = document.getElementById('tab-nav');
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
const container = document.querySelector('.grid-stack');
|
nav.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
if (!container) return;
|
btn.addEventListener('click', () => {
|
||||||
|
const tab = btn.getAttribute('data-tab');
|
||||||
this._grid = GridStack.init({
|
if (tab) this.switchTab(tab);
|
||||||
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');
|
nav.style.display = '';
|
||||||
if (toolbar) toolbar.style.display = 'flex';
|
|
||||||
|
|
||||||
this._syncToggles();
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
_applyLayout(layout) {
|
switchTab(tabId, save = true) {
|
||||||
if (!this._grid) return;
|
if (!this.TAB_ORDER.includes(tabId)) tabId = 'zusammenfassung';
|
||||||
|
|
||||||
this._hiddenTiles = {};
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
layout.forEach(item => {
|
// Leaflet-Karte: invalidateSize nach Panel-Wechsel, damit Tiles korrekt rendern
|
||||||
const el = this._grid.engine.nodes.find(n => n.el && n.el.getAttribute('gs-id') === item.id);
|
if (tabId === 'karte' && typeof UI !== 'undefined' && UI._map) {
|
||||||
if (!el) return;
|
setTimeout(() => { try { UI._map.invalidateSize(); } catch (e) { /* ignore */ } }, 50);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(this._hiddenTiles).forEach(id => {
|
|
||||||
items.push({ ...this._hiddenTiles[id], visible: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (save && this._currentIncidentId != null) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this._storageKey, JSON.stringify(items));
|
localStorage.setItem('osint_tab_' + this._currentIncidentId, tabId);
|
||||||
} catch (e) { /* quota */ }
|
} catch (e) { /* quota */ }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_debouncedSave() {
|
restoreTabFor(incidentId) {
|
||||||
clearTimeout(this._saveTimeout);
|
this._currentIncidentId = incidentId;
|
||||||
this._saveTimeout = setTimeout(() => this.save(), 300);
|
let target = 'zusammenfassung';
|
||||||
},
|
|
||||||
|
|
||||||
_load() {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this._storageKey);
|
const saved = localStorage.getItem('osint_tab_' + incidentId);
|
||||||
if (!raw) return null;
|
if (saved && this.TAB_ORDER.includes(saved)) target = saved;
|
||||||
const parsed = JSON.parse(raw);
|
} catch (e) { /* ignore */ }
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
this.switchTab(target, false);
|
||||||
return parsed;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTile(tileId) {
|
/** Tab-Labels je Incident-Typ anpassen (adhoc vs. research). */
|
||||||
if (!this._grid) return;
|
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';
|
||||||
|
},
|
||||||
|
|
||||||
const selector = this.TILE_MAP[tileId];
|
// Legacy-API-Stubs: falls alte Aufrufe im Code liegen, stumm schlucken statt crashen.
|
||||||
if (!selector) return;
|
toggleTile() { /* legacy no-op */ },
|
||||||
|
reset() { /* legacy no-op */ },
|
||||||
if (this._hiddenTiles[tileId]) {
|
save() { /* legacy no-op */ },
|
||||||
// Kachel einblenden
|
resizeTileToContent() { /* legacy no-op */ },
|
||||||
const cfg = this._hiddenTiles[tileId];
|
destroy() { /* legacy no-op */ },
|
||||||
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
|
document.addEventListener('DOMContentLoaded', () => LayoutManager.init());
|
||||||
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 = {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 += ' — ' + 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
592
src/static/js/pipeline.js
Normale Datei
@@ -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">×</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 => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[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
265
src/static/js/update-system.js
Normale Datei
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -34,6 +34,10 @@ const WS = {
|
|||||||
console.log('WebSocket verbunden');
|
console.log('WebSocket verbunden');
|
||||||
this.reconnectDelay = 2000;
|
this.reconnectDelay = 2000;
|
||||||
this._startPing();
|
this._startPing();
|
||||||
|
// Nach Reconnect: Refresh-Status mit Server abgleichen
|
||||||
|
if (typeof App !== 'undefined' && App.syncRefreshStatus) {
|
||||||
|
App.syncRefreshStatus();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren