Commits vergleichen

21 Commits

Autor SHA1 Nachricht Datum
Claude Code
2df37cb617 Update-System: /api/version + /api/release-notes + RELEASES.json
Frontend kann jetzt erkennen, wann eine neue Version live ist, und dem Nutzer
einen passenden Hinweis sowie die Release-Notes anzeigen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:42:08 +02:00
24 geänderte Dateien mit 1638 neuen und 283 gelöschten Zeilen

1
.gitignore vendored
Datei anzeigen

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

125
CLAUDE.md
Datei anzeigen

@@ -220,3 +220,128 @@ Changelog-Kategorien in TaskMate:
- 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**

12
RELEASES.json Normale Datei
Datei anzeigen

@@ -0,0 +1,12 @@
[
{
"version": "5473ba3",
"date": "2026-04-26",
"title": "Update-System eingefuehrt",
"items": [
"Updates beruehren ab jetzt nie mehr die Faelle oder Daten",
"Beim Promote landet eine 'Was ist neu'-Info hier",
"Strukturelle Trennung von Live- und Staging-Datenbank"
]
}
]

1
data
Datei anzeigen

@@ -1 +0,0 @@
/home/claude-dev/osint-data

Datei anzeigen

@@ -11,3 +11,4 @@ python-multipart
aiosmtplib
geonamescache>=2.0
telethon
pikepdf>=9.0

Datei anzeigen

@@ -206,7 +206,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
LATEST_DEVELOPMENTS_PROMPT_TEMPLATE = """Du pflegst eine Kachel "Neueste Entwicklungen" für eine Live-Monitoring-Lage.
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).
@@ -214,37 +214,60 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
LAGE: {title}
KONTEXT: {description}
BISHERIGE ENTWICKLUNGEN (chronologisch absteigend, neueste oben):
{previous_developments}
AKTUELLES LAGEBILD (autoritative inhaltliche Grundlage):
{summary}
NEUE MELDUNGEN SEIT DEM LETZTEN UPDATE:
{new_articles_text}
BELEGENDE MELDUNGEN (chronologisch absteigend, neueste zuerst — nur hieraus dürfen Zeitstempel und Quellen-Klammern stammen):
{articles_text}
AUFTRAG:
Extrahiere aus den NEUEN Meldungen konkrete Ereignisse und aktualisiere die Liste. Fasse die bisherigen und neuen Ereignisse zu EINER Liste zusammen (max. 8 Bullets, neueste oben).
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:
- Jedes Bullet = EIN konkretes Ereignis (1-2 Sätze, faktenbasiert). Keine Themen-Zusammenfassungen.
- Jedes Bullet beginnt mit dem Zeitstempel der frühesten belegenden Quelle im Format "[DD.MM. HH:MM]".
- Jedes Bullet ENDET mit einer Quellen-Klammer — ZWINGEND. Bullets ohne Klammer werden verworfen.
- NEUE Bullets (aus den NEUEN MELDUNGEN): {{M<ID1>, M<ID2>}} mit den ganzzahligen IDs aus der "ID:"-Zeile der belegenden Meldung(en). Beispiele: {{M42}} oder {{M42, M17}}.
- UEBERNOMMENE Bullets aus BISHERIGE ENTWICKLUNGEN: behalten ihre bestehende Klammer KOMPLETT UND UNVERAENDERT, inklusive des Pipe-Zeichens und der URL. Beispiel: {{Reuters|https://reuters.com/article, Rybar|https://t.me/rybar/123}}. NICHT in M-IDs umwandeln, NICHT die URL entfernen, NICHT umformatieren.
- Wenn mehrere Meldungen dasselbe Ereignis belegen: EIN Bullet, Zeitstempel = frühester Zeitpunkt, ALLE IDs in der Klammer.
- Bestehende Bullets aus BISHERIGE ENTWICKLUNGEN sinngemäß übernehmen, NICHT umformulieren. Nur entfernen, wenn sie durch neue Meldungen nachweislich überholt sind oder die 8-Bullet-Grenze überschritten wird (dann älteste fallen raus). Wenn einem uebernommenen Bullet die Quellen-Klammer fehlt (Altformat): Bullet VERWERFEN und nicht in die neue Liste uebernehmen.
- Wenn eine Quelle eine erkennbare politische Ausrichtung hat (z.B. pro-russisch, staatsnah, rechtsextrem), im Bullet-Text erwähnen ("laut pro-russischem Telegram-Kanal Rybar...").
- Neutral und sachlich — keine Wertungen oder Spekulationen.
- KEINE Gedankenstriche (—, –) — stattdessen Kommas, Doppelpunkte oder neue Sätze.
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.
- Wenn aus den neuen Meldungen kein neues Ereignis extrahierbar ist: BISHERIGE ENTWICKLUNGEN unverändert zurückgeben.
OUTPUT-FORMAT (ausschliesslich, keine Anführungszeichen, kein Code-Fence, JEDE Zeile beginnt mit "- "):
- [DD.MM. HH:MM] Ereignistext neu. {{M<ID>}}
- [DD.MM. HH:MM] Ereignistext neu mit mehreren Belegen. {{M<ID1>, M<ID2>}}
- [DD.MM. HH:MM] Ereignistext aus BISHERIGE ENTWICKLUNGEN. {{Quellenname1|URL1, Quellenname2|URL2}}
OUTPUT-FORMAT (ausschliesslich, kein Code-Fence, JEDE Zeile beginnt mit "- "):
- [DD.MM. HH:MM] Ereignistext. {{Quellenname1|URL1}}
- [DD.MM. HH:MM] Ereignistext mit mehreren Belegen. {{Quellenname1|URL1, Quellenname2|URL2}}
..."""
TOPIC_FILTER_PROMPT_TEMPLATE = """Du bist ein OSINT-Relevanzfilter. Ein vorgeschalteter Keyword-Prefilter hat diese Artikel für eine Lage durchgelassen — aber Keyword-Treffer allein reichen nicht. Artikel müssen das SPEZIFISCHE KERNTHEMA der Lage inhaltlich behandeln.
LAGE: {title}
KONTEXT: {description}
ARTIKEL-KANDIDATEN:
{articles_text}
AUFGABE:
Entscheide je Artikel, ob er thematisch zur Lage passt, und gib die laufenden Nummern der relevanten Artikel zurück.
REGELN:
- Relevant = der Artikel behandelt konkret das im Titel + Kontext beschriebene Kernthema. Zentrale Akteure, Handlungen, Aussagen oder Ereignisse des Themas müssen im Artikel erkennbar sein.
- NICHT relevant = Artikel, die nur allgemeine Begriffe aus dem Thema streifen (z.B. "Russland", "Iran", "Krieg", "Drohne"), ohne das Spezifikum der Lage zu behandeln. Allgemeine Kontext-Berichte aus der gleichen Region oder zum gleichen Großkonflikt sind NICHT automatisch relevant.
- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln.
- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt.
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer.
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
{{"relevant_ids": [1, 3, 7]}}"""
class AnalyzerAgent:
"""Analysiert und übersetzt Meldungen über Claude CLI."""
@@ -379,32 +402,127 @@ class AnalyzerAgent:
logger.error(f"Inkrementelle Analyse-Fehler: {e}")
return None, None
async def filter_relevant_articles(
self,
title: str,
description: str,
articles: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Semantischer Topic-Filter (Haiku).
Nimmt die vom Keyword-Prefilter durchgelassenen Artikel und wirft diejenigen raus,
die zwar auf Keywords matchen, aber das Kernthema der Lage thematisch nicht treffen.
Fällt bei Parsing- oder API-Fehlern auf die unveränderte Liste zurück.
"""
if not articles:
return articles, None
lines = []
for i, article in enumerate(articles, 1):
headline = article.get("headline_de") or article.get("headline", "")
source = article.get("source", "Unbekannt")
content = article.get("content_de") or article.get("content_original") or ""
lines.append(f"[{i}] Quelle: {source}")
lines.append(f" Überschrift: {headline}")
if content:
lines.append(f" Inhalt: {content[:400]}")
articles_text = "\n".join(lines)
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
title=title,
description=description or "Keine weiteren Details",
articles_text=articles_text,
)
from config import CLAUDE_MODEL_FAST
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.warning(f"Topic-Filter-Fehler (behalte alle {len(articles)} Artikel): {e}")
return articles, None
parsed = self._parse_response(result)
if not parsed or not isinstance(parsed.get("relevant_ids"), list):
logger.warning(
f"Topic-Filter: keine relevant_ids geparst, behalte alle {len(articles)} Artikel"
)
return articles, usage
relevant_set = {
i for i in parsed["relevant_ids"]
if isinstance(i, int) and 1 <= i <= len(articles)
}
filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]
rejected = len(articles) - len(filtered)
if not filtered and articles:
logger.warning(
f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
"möglicherweise zu aggressiv. Behalte Original."
)
return articles, usage
logger.info(
f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
f"({rejected} verworfen)"
)
return filtered, usage
async def generate_latest_developments(
self,
title: str,
description: str,
new_articles: list[dict],
previous_developments: str | None,
summary: str,
recent_articles: list[dict],
previous_developments: str | None = None,
) -> tuple[str | None, ClaudeUsage | None]:
"""Pflegt die Kachel 'Neueste Entwicklungen' für Live-Monitoring-Lagen.
"""Generiert die Kachel 'Neueste Entwicklungen' aus dem Lagebild.
Gibt Markdown-Bullets mit Zeitstempel zurück (max 8, neueste oben).
Wenn keine neuen Artikel vorliegen, werden die bisherigen Bullets unverändert zurückgegeben.
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()
if not new_articles:
return (prev or None), 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")
new_articles_text = self._format_articles_text(new_articles, max_articles=25)
prev_block = prev if prev else "(noch keine Einträge)"
# 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",
previous_developments=prev_block,
new_articles_text=new_articles_text,
summary=summary.strip(),
articles_text=articles_text,
today=today,
output_language=OUTPUT_LANGUAGE,
)
@@ -413,16 +531,16 @@ class AnalyzerAgent:
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 or None), None
return prev, None
bullets = self._parse_latest_developments(result, new_articles)
bullets = self._parse_latest_developments(result, recent_articles)
if not bullets:
logger.info("Latest-Developments: keine Bullets geparst, behalte bisherigen Stand")
return (prev or None), usage
return prev, usage
bullets = bullets[:8]
bullets = bullets[:6]
output = "\n".join(bullets)
logger.info(f"Latest-Developments: {len(bullets)} Bullets generiert")
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
return output, usage
@staticmethod

Datei anzeigen

@@ -13,6 +13,35 @@ _cancel_event_var: contextvars.ContextVar[asyncio.Event | None] = contextvars.Co
logger = logging.getLogger("osint.claude_client")
class ClaudeCliError(RuntimeError):
"""Strukturierter Fehler aus dem Claude CLI mit Kategorie.
error_type:
- "rate_limit": Anthropic Rate-Limit oder Overload (transient, retry-tauglich)
- "auth_error": Account-Problem (Organisation hat keinen Claude-Zugang,
Token abgelaufen/ungueltig) - kein Retry sinnvoll, Admin-Aktion noetig
- "timeout": Claude CLI Timeout (transient)
- "cli_error": Sonstiger CLI-Fehler (unspezifisch, Default)
"""
def __init__(self, error_type: str, message: str):
self.error_type = error_type
self.message = message
super().__init__(f"Claude CLI [{error_type}]: {message}")
def _classify_cli_error(combined_output: str) -> str:
"""Ordnet einer Fehler-Ausgabe eine error_type-Kategorie zu."""
txt = combined_output.lower()
rate_limit_keywords = ["hit your limit", "rate limit", "resets", "rate_limit", "overloaded"]
auth_error_keywords = ["does not have access", "login again", "contact your administrator"]
if any(kw in txt for kw in rate_limit_keywords):
return "rate_limit"
if any(kw in txt for kw in auth_error_keywords):
return "auth_error"
return "cli_error"
@dataclass
class ClaudeUsage:
"""Token-Verbrauch eines einzelnen Claude CLI Aufrufs."""
@@ -48,7 +77,7 @@ 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) -> tuple[str, ClaudeUsage]:
async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", model: str | None = None, raw_text: bool = False, timeout: float | None = None) -> tuple[str, ClaudeUsage]:
"""Ruft Claude CLI auf. Gibt (result_text, usage) zurück.
Prompt wird via stdin uebergeben um OS ARG_MAX Limits zu vermeiden.
@@ -57,8 +86,10 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
prompt: Der Prompt fuer Claude
tools: Kommagetrennte erlaubte Tools (None = keine Tools, --max-turns 1)
model: Optionales Modell (z.B. CLAUDE_MODEL_FAST fuer Haiku). None = CLAUDE_MODEL_STANDARD (Opus 4.7).
timeout: Override in Sekunden. None = Fallback auf globalen CLAUDE_TIMEOUT (1800s).
"""
effective_model = model or CLAUDE_MODEL_STANDARD
effective_timeout = timeout if timeout is not None else CLAUDE_TIMEOUT
cmd = [CLAUDE_PATH, "-p", "-", "--output-format", "json", "--model", effective_model]
if tools:
cmd.extend(["--allowedTools", tools])
@@ -89,7 +120,7 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
process.communicate(input=prompt.encode("utf-8"))
)
cancel_wait_task = asyncio.create_task(cancel_event.wait())
timeout_task = asyncio.create_task(asyncio.sleep(CLAUDE_TIMEOUT))
timeout_task = asyncio.create_task(asyncio.sleep(effective_timeout))
done, pending = await asyncio.wait(
[communicate_task, cancel_wait_task, timeout_task],
@@ -108,32 +139,33 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
else:
process.kill()
await process.wait()
raise TimeoutError(f"Claude CLI Timeout nach {CLAUDE_TIMEOUT}s")
raise TimeoutError(f"Claude CLI Timeout nach {effective_timeout}s")
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")), timeout=CLAUDE_TIMEOUT
process.communicate(input=prompt.encode("utf-8")), timeout=effective_timeout
)
except asyncio.TimeoutError:
process.kill()
raise TimeoutError(f"Claude CLI Timeout nach {CLAUDE_TIMEOUT}s")
raise TimeoutError(f"Claude CLI Timeout nach {effective_timeout}s")
if process.returncode != 0:
error_msg = stderr.decode("utf-8", errors="replace").strip()
stdout_msg = stdout.decode("utf-8", errors="replace").strip()
# Rate-Limit-Fehler kommen als JSON auf stdout, nicht auf stderr
error_type = "cli_error"
rate_limit_keywords = ["hit your limit", "rate limit", "resets", "rate_limit", "overloaded"]
combined_output = f"{error_msg} {stdout_msg}".lower()
if any(kw in combined_output for kw in rate_limit_keywords):
error_type = "rate_limit"
# Rate-Limit/Auth-Fehler kommen teils als JSON auf stdout, nicht auf stderr
combined_output = f"{error_msg} {stdout_msg}"
error_type = _classify_cli_error(combined_output)
if error_type == "rate_limit":
logger.warning(f"Claude CLI Rate-Limit (Exit {process.returncode}): {stdout_msg or error_msg}")
elif error_type == "auth_error":
logger.error(f"Claude CLI Auth-Fehler (Exit {process.returncode}): {stdout_msg or error_msg}")
else:
logger.error(f"Claude CLI Fehler (Exit {process.returncode}): {error_msg}")
if stdout_msg:
logger.error(f"Claude CLI stdout bei Fehler: {stdout_msg[:500]}")
raise RuntimeError(f"Claude CLI Fehler [{error_type}]: {stdout_msg or error_msg}")
raise ClaudeCliError(error_type, stdout_msg or error_msg)
raw = stdout.decode("utf-8", errors="replace").strip()
usage = ClaudeUsage()
@@ -141,6 +173,19 @@ async def call_claude(prompt: str, tools: str | None = "WebSearch,WebFetch", mod
try:
data = json.loads(raw)
# CLI kann returncode=0 liefern und trotzdem is_error=true setzen
# (z.B. "Your organization does not have access to Claude")
if data.get("is_error"):
error_text = str(data.get("result", ""))
error_type = _classify_cli_error(error_text)
if error_type == "rate_limit":
logger.warning(f"Claude CLI Rate-Limit (is_error): {error_text}")
elif error_type == "auth_error":
logger.error(f"Claude CLI Auth-Fehler (is_error): {error_text}")
else:
logger.error(f"Claude CLI Fehler (is_error): {error_text}")
raise ClaudeCliError(error_type, error_text)
result_text = data.get("result", raw)
u = data.get("usage", {})
usage = ClaudeUsage(

Datei anzeigen

@@ -395,6 +395,10 @@ class AgentOrchestrator:
self._queue: asyncio.Queue = asyncio.Queue()
self._running = False
self._current_task: Optional[int] = None
# Session-Start des aktuellen Tasks (UTC ISO mit 'Z'). Ueberspannt Multi-Pass
# und Retries innerhalb derselben Queue-Abarbeitung — verhindert, dass der
# Frontend-Timer beim Seiten-Reload auf den Pass/Retry-Start zurueckspringt.
self._current_task_started_at: Optional[str] = None
self._ws_manager = None
self._queued_ids: set[int] = set()
self._cancel_requested: set[int] = set()
@@ -515,14 +519,20 @@ class AgentOrchestrator:
user_id = None
self._queued_ids.discard(incident_id)
self._current_task = incident_id
# Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil
self._current_task_started_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
self._cancel_event = asyncio.Event()
_cancel_event_var.set(self._cancel_event)
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
RETRY_DELAYS = [0, 120, 300] # Sekunden: sofort, 2min, 5min
TRANSIENT_ERRORS = (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)
from agents.claude_client import ClaudeCliError
last_error = None
def _is_transient_cli(err: Exception) -> bool:
return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout")
try:
# Research-Lagen: Automatisch 3 Durchläufe nur beim ersten Refresh
incident_type, has_summary = await self._get_incident_info(incident_id)
@@ -551,32 +561,44 @@ class AgentOrchestrator:
}, _vis, _cb, _tid)
last_error = None
break
except TRANSIENT_ERRORS as e:
last_error = e
logger.warning(f"Transienter Fehler bei Lage {incident_id} (Versuch {attempt + 1}/3): {e}")
if attempt < 2:
await self._mark_refresh_failed(incident_id, str(e))
delay = RETRY_DELAYS[attempt + 1]
logger.info(f"Retry in {delay}s für Lage {incident_id}")
# Retry-Status per WebSocket senden
if self._ws_manager:
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_vis, _cb, _tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "retrying", "attempt": attempt + 1, "delay": delay},
}, _vis, _cb, _tid)
await asyncio.sleep(delay)
else:
await self._mark_refresh_failed(incident_id, f"Endgültig fehlgeschlagen nach 3 Versuchen: {e}")
except Exception as e:
# Auth/CLI-Fehler: sofort abbrechen, kein Retry sinnvoll
if isinstance(e, ClaudeCliError) and e.error_type in ("auth_error", "cli_error"):
last_error = e
logger.error(f"Permanenter Claude-Fehler [{e.error_type}] bei Lage {incident_id}: {e}")
await self._mark_refresh_failed(incident_id, str(e))
break
# Transiente Fehler: Retry bis 3x
if isinstance(e, TRANSIENT_ERRORS) or _is_transient_cli(e):
last_error = e
kind = e.error_type if isinstance(e, ClaudeCliError) else type(e).__name__
logger.warning(f"Transienter Fehler [{kind}] bei Lage {incident_id} (Versuch {attempt + 1}/3): {e}")
if attempt < 2:
await self._mark_refresh_failed(incident_id, str(e))
delay = RETRY_DELAYS[attempt + 1]
logger.info(f"Retry in {delay}s für Lage {incident_id}")
if self._ws_manager:
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_vis, _cb, _tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "retrying", "attempt": attempt + 1, "delay": delay},
}, _vis, _cb, _tid)
await asyncio.sleep(delay)
continue
else:
await self._mark_refresh_failed(incident_id, f"Endgültig fehlgeschlagen nach 3 Versuchen: {e}")
break
# Alles andere: permanent
last_error = e
logger.error(f"Permanenter Fehler bei Refresh für Lage {incident_id}: {e}")
await self._mark_refresh_failed(incident_id, str(e))
break # Permanenter Fehler, kein Retry
break
if last_error and self._ws_manager:
try:
@@ -590,6 +612,7 @@ class AgentOrchestrator:
}, _vis, _cb, _tid)
finally:
self._current_task = None
self._current_task_started_at = None
self._cancel_event = None
_cancel_event_var.set(None)
self._queue.task_done()
@@ -933,18 +956,15 @@ class AgentOrchestrator:
logger.info(f"DB-Dedup: {len(existing_urls)} URLs, {len(existing_headlines)} Headlines im Bestand")
# Neue Artikel speichern und für Analyse tracken
new_count = 0
new_articles_for_analysis = []
# --- Dedup gegen Bestand: nur neue (noch nicht gespeicherte) Kandidaten behalten ---
new_candidates = []
for article in unique_results:
# URL-Duplikat gegen DB
if article.get("source_url"):
norm_url = _normalize_url(article["source_url"])
if norm_url in existing_urls:
continue
existing_urls.add(norm_url)
# Headline-Duplikat gegen DB
headline = article.get("headline", "")
if headline and len(headline) > 20:
norm_h = _normalize_headline(headline)
@@ -953,6 +973,23 @@ class AgentOrchestrator:
if norm_h:
existing_headlines.add(norm_h)
new_candidates.append(article)
# --- Semantischer Topic-Filter (Haiku) ---
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
if new_candidates:
_tf_agent = AnalyzerAgent()
new_candidates, _tf_usage = await _tf_agent.filter_relevant_articles(
title, description, new_candidates,
)
if _tf_usage:
usage_acc.add(_tf_usage)
# --- Neue (thematisch gefilterte) Artikel speichern und für Analyse tracken ---
new_count = 0
new_articles_for_analysis = []
for article in new_candidates:
cursor = await db.execute(
"""INSERT INTO articles (incident_id, headline, headline_de, source,
source_url, content_original, content_de, language, published_at, tenant_id)
@@ -971,7 +1008,6 @@ class AgentOrchestrator:
),
)
new_count += 1
# Artikel mit DB-ID für die Analyse tracken
article_with_id = dict(article)
article_with_id["id"] = cursor.lastrowid
new_articles_for_analysis.append(article_with_id)
@@ -1273,11 +1309,24 @@ class AgentOrchestrator:
self._check_cancelled(incident_id)
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
if incident_type == "adhoc" and new_articles_for_analysis:
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.
dev_summary_source = (locals().get("new_summary") or previous_summary or "").strip()
if incident_type == "adhoc" and dev_summary_source:
try:
# Top-60 neueste Artikel mit Publikationsdatum als Beleg-Pool.
dev_cursor = await db.execute(
"""SELECT id, headline, headline_de, source, source_url, published_at
FROM articles
WHERE incident_id = ? AND published_at IS NOT NULL
ORDER BY published_at DESC LIMIT 60""",
(incident_id,),
)
dev_articles = [dict(row) for row in await dev_cursor.fetchall()]
dev_analyzer = AnalyzerAgent()
dev_text, dev_usage = await dev_analyzer.generate_latest_developments(
title, description, new_articles_for_analysis, previous_developments,
title, description, dev_summary_source, dev_articles, previous_developments,
)
if dev_usage:
usage_acc.add(dev_usage)
@@ -1495,38 +1544,9 @@ class AgentOrchestrator:
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
if tenant_id and usage_acc.total_cost_usd > 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 (?, ?, 'monitor', ?, ?, ?, ?, ?, ?, 1)
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 + 1,
updated_at = CURRENT_TIMESTAMP
""", (tenant_id, year_month,
usage_acc.input_tokens, usage_acc.output_tokens,
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
round(usage_acc.total_cost_usd, 7), usage_acc.call_count))
# Credits auf Lizenz abziehen
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()
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
credits_consumed = usage_acc.total_cost_usd / 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))
from services.license_service import charge_usage_to_tenant
await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor")
await db.commit()
logger.info(f"Credits: {round(credits_consumed, 1) if lic and lic['cost_per_credit'] else 0} abgezogen für Tenant {tenant_id}")
# Quellen-Discovery im Background starten
if unique_results:

Datei anzeigen

@@ -10,7 +10,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
LOG_DIR = os.path.join(BASE_DIR, "logs")
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
DB_PATH = os.path.join(DATA_DIR, "osint.db")
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "osint.db")
# JWT
_JWT_SECRET = os.environ.get("JWT_SECRET", "")

Datei anzeigen

@@ -583,6 +583,7 @@ async def init_db():
for idx_sql in [
"CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status ON incidents(tenant_id, status)",
"CREATE INDEX IF NOT EXISTS idx_articles_tenant_incident ON articles(tenant_id, incident_id)",
"CREATE INDEX IF NOT EXISTS idx_articles_incident_collected ON articles(incident_id, collected_at DESC)",
]:
try:
await db.execute(idx_sql)

Datei anzeigen

@@ -378,6 +378,7 @@ from routers.feedback import router as feedback_router
from routers.public_api import router as public_api_router
from routers.chat import router as chat_router
from routers.tutorial import router as tutorial_router
from routes.version_router import router as version_router
app.include_router(auth_router)
app.include_router(incidents_router)
@@ -387,6 +388,7 @@ app.include_router(feedback_router)
app.include_router(public_api_router)
app.include_router(chat_router, prefix="/api/chat")
app.include_router(tutorial_router)
app.include_router(version_router)
@app.websocket("/api/ws")

Datei anzeigen

@@ -78,6 +78,11 @@ class DescriptionEnhanceRequest(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
title: str
description: Optional[str]
@@ -90,7 +95,6 @@ class IncidentResponse(BaseModel):
visibility: str = "public"
summary: Optional[str]
latest_developments: Optional[str] = None
sources_json: Optional[str] = None
international_sources: bool = True
include_telegram: bool = False
created_by: int
@@ -101,6 +105,35 @@ class IncidentResponse(BaseModel):
source_count: int = 0
class IncidentListItem(BaseModel):
"""Schlankes Sidebar-Item (fuer GET /incidents).
Enthaelt, was Sidebar und Edit-Dialog brauchen — kein summary,
kein sources_json. Statt summary-Volltext ein ``has_summary``-Bit,
damit das Frontend "erster Refresh"-Zustand erkennen kann.
description bleibt drin (kurz, vom Edit-Modal direkt genutzt).
"""
id: int
title: str
description: Optional[str] = None
type: str
status: str
refresh_mode: str
refresh_interval: int
refresh_start_time: Optional[str] = None
retention_days: int
visibility: str = "public"
international_sources: bool = True
include_telegram: bool = False
created_by: int
created_by_username: str = ""
created_at: str
updated_at: str
article_count: int = 0
source_count: int = 0
has_summary: bool = False
# Sources (Quellenverwaltung)

Datei anzeigen

@@ -4,10 +4,12 @@ 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
@@ -391,10 +393,267 @@ LAGEBILD:
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
@@ -424,6 +683,11 @@ async def generate_pdf(
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")
@@ -449,6 +713,7 @@ async def generate_pdf(
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
@@ -461,6 +726,7 @@ async def generate_pdf(
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
@@ -468,6 +734,9 @@ 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()
@@ -485,7 +754,7 @@ async def generate_docx(
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfuegbar."
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = []
@@ -496,6 +765,28 @@ async def generate_docx(
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)

Datei anzeigen

@@ -1,7 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<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; }

Datei anzeigen

@@ -12,6 +12,11 @@ from pydantic import BaseModel, Field
from auth import get_current_user
from config import CLAUDE_PATH, CLAUDE_MODEL_FAST
from database import db_dependency
from middleware.license_check import require_writable_license
from services.license_service import charge_usage_to_tenant
from agents.claude_client import ClaudeUsage, ClaudeCliError, _classify_cli_error
import aiosqlite
logger = logging.getLogger("osint.chat")
@@ -21,8 +26,8 @@ router = APIRouter(tags=["chat"])
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
# ---------------------------------------------------------------------------
async def _call_claude_chat(prompt: str) -> tuple[str, int]:
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms) zurueck.
async def _call_claude_chat(prompt: str) -> tuple[str, int, ClaudeUsage]:
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms, usage) zurueck.
Anders als call_claude(): kein JSON-Output-Modus, kein append-system-prompt.
"""
@@ -46,7 +51,7 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]:
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")), timeout=60
process.communicate(input=prompt.encode("utf-8")), timeout=120
)
except asyncio.TimeoutError:
process.kill()
@@ -54,29 +59,44 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]:
if process.returncode != 0:
err_msg = stderr.decode("utf-8", errors="replace").strip()
logger.error(f"Chat Claude CLI Fehler (rc={process.returncode}): {err_msg[:500]}")
if "rate_limit" in err_msg.lower() or "overloaded" in err_msg.lower():
raise RuntimeError("rate_limit")
raise RuntimeError(f"Claude CLI Fehler: {err_msg[:200]}")
stdout_msg = stdout.decode("utf-8", errors="replace").strip()
combined = f"{err_msg} {stdout_msg}"
error_type = _classify_cli_error(combined)
logger.error(f"Chat Claude CLI Fehler [{error_type}] (rc={process.returncode}): {(stdout_msg or err_msg)[:500]}")
raise ClaudeCliError(error_type, stdout_msg or err_msg)
raw = stdout.decode("utf-8", errors="replace").strip()
duration_ms = 0
result_text = raw
usage = ClaudeUsage()
try:
data = _json.loads(raw)
if data.get("is_error"):
error_text = str(data.get("result", ""))
error_type = _classify_cli_error(error_text)
logger.error(f"Chat Claude CLI Fehler [{error_type}] (is_error): {error_text[:500]}")
raise ClaudeCliError(error_type, error_text)
result_text = data.get("result", raw)
duration_ms = data.get("duration_ms", 0)
cost = data.get("total_cost_usd", 0.0)
u = data.get("usage", {})
usage = ClaudeUsage(
input_tokens=u.get("input_tokens", 0),
output_tokens=u.get("output_tokens", 0),
cache_creation_tokens=u.get("cache_creation_input_tokens", 0),
cache_read_tokens=u.get("cache_read_input_tokens", 0),
cost_usd=data.get("total_cost_usd", 0.0),
duration_ms=duration_ms,
)
logger.info(
f"Chat Claude: {u.get('input_tokens', 0)} in / {u.get('output_tokens', 0)} out / "
f"${cost:.4f} / {duration_ms}ms"
f"Chat Claude: {usage.input_tokens} in / {usage.output_tokens} out / "
f"${usage.cost_usd:.4f} / {duration_ms}ms"
)
except _json.JSONDecodeError:
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
return result_text, duration_ms
return result_text, duration_ms, usage
# ---------------------------------------------------------------------------
# Models
@@ -298,7 +318,7 @@ Typische Fragen die du beantworten kannst:
FEATURE-DOKUMENTATION:
Lage/Recherche erstellen:
Oben im Dashboard gibt es den Button "Neue Lage". Dort waehlt der Nutzer unter "Art der Lage" zwischen zwei Typen. "Live-Monitoring, Ereignis beobachten" durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen zu einem aktuellen Ereignis, 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, das System nutzt dann KI-gestuetzte Quellenauswahl und eine breitere Suche. Empfohlen ist manuelles Starten und bei Bedarf vertiefen. Bei beiden Typen gibt der Nutzer Titel und Beschreibung ein und klickt "Lage anlegen". Der erste Refresh startet automatisch und sammelt passende Artikel. In der Sidebar werden Live-Monitoring Lagen unter "Live-Monitoring" und Recherchen unter "Recherchen" gruppiert angezeigt.
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.
@@ -308,17 +328,17 @@ Je praeziser die Beschreibung, desto relevantere Ergebnisse liefert das System.
Quellen:
Quellen werden automatisch vom System verwaltet. Es gibt verschiedene Kategorien: oeffentlich-rechtlich, Qualitaetszeitung, Nachrichtenagentur, international, Behoerde, Telegram und sonstige. Unter den Quellen-Einstellungen koennen bestimmte Domains blockiert werden, damit deren Artikel nicht mehr in Lagen erscheinen. Das System schlaegt auch automatisch neue relevante Quellen vor basierend auf den Themen der Lagen. Die Quellenansicht zeigt fuer jede Quelle Name, Kategorie, Typ, Artikelanzahl und wann zuletzt Artikel gefunden wurden.
Refresh-Modi:
Jede Lage hat einen Refresh-Modus. "Manuell" bedeutet, der Nutzer klickt selbst auf "Aktualisieren" wenn er neue Artikel suchen moechte. "Automatisch" laesst das System in einem einstellbaren Intervall automatisch nach neuen Artikeln suchen. Das Intervall ist pro Lage einstellbar, z.B. alle 15, 30, 60 oder 180 Minuten. Bei einem Refresh durchsucht das System alle konfigurierten Quellen nach neuen relevanten Artikeln, erstellt oder aktualisiert die Zusammenfassung und fuehrt Faktenchecks durch.
Aktualisierungs-Modi:
Jede Lage hat einen Aktualisierungs-Modus. "Manuell" bedeutet, der Nutzer klickt selbst auf "Aktualisieren" wenn er neue Artikel suchen moechte. "Automatisch" laesst die Lage in einem selbst gewaehlten Intervall turnusmaessig nach neuen Artikeln suchen. Das Intervall kann in Minuten, Stunden, Tagen oder Wochen angegeben werden, mindestens 10 Minuten. Im Automatik-Modus laesst sich ausserdem eine Uhrzeit fuer die erste Aktualisierung festlegen, danach laeuft es im gewaehlten Takt weiter. Bei jeder Aktualisierung kommen neue Artikel hinzu, die Zusammenfassung wird aktualisiert und die Faktenchecks werden neu bewertet.
Faktenchecks:
Das System prueft automatisch Behauptungen aus den gesammelten Artikeln. Es gibt vier Status: "Bestaetigt" bedeutet mehrere unabhaengige Quellen bestaetigen die Information. "Umstritten" heisst Quellen widersprechen sich und die Faktenlage ist unklar. "Widerlegt" bedeutet die Information wurde durch zuverlaessige Quellen widerlegt. "In Entwicklung" zeigt an dass noch nicht genug Informationen fuer eine Einschaetzung vorliegen. Die Faktenchecks werden bei jedem Refresh automatisch aktualisiert und koennen sich im Laufe der Zeit aendern wenn neue Evidenz hinzukommt.
In der Faktencheck-Kachel werden zentrale Behauptungen aus den Artikeln mit einem Status markiert. Es gibt fuenf Status: "Bestaetigt" (gruenes Haekchen) heisst, mindestens zwei unabhaengige, serioese Quellen stuetzen die Aussage uebereinstimmend. "Gesichert" (gruenes Haekchen) bedeutet, drei oder mehr unabhaengige Quellen belegen den Sachverhalt, hohe Verlaesslichkeit. "Unbestaetigt" (Fragezeichen) zeigt an, dass die Aussage bisher nur aus einer Quelle stammt und eine unabhaengige Bestaetigung aussteht. "Umstritten" (Warndreieck) bedeutet, Quellen widersprechen sich, es gibt sowohl stuetzende als auch widersprechende Belege. "Widerlegt" (rotes Kreuz) heisst, zuverlaessige Quellen widersprechen der Aussage und sie ist wahrscheinlich falsch. Der Status kann sich bei spaeteren Aktualisierungen aendern, wenn neue Belege hinzukommen.
Benachrichtigungen und Abos:
Lagen koennen ueber das Glocken-Symbol abonniert werden. Es gibt verschiedene E-Mail-Benachrichtigungstypen: Zusammenfassung nach einem Refresh, Benachrichtigung bei neuen Artikeln und Benachrichtigung bei Statusaenderungen von Faktenchecks. Im Dashboard erscheinen neue Benachrichtigungen als Badge am Glocken-Symbol. Welche Benachrichtigungstypen gewuenscht sind, laesst sich pro Lage einzeln einstellen.
Lagen koennen ueber das Glocken-Symbol abonniert werden. Beim Anlegen oder Bearbeiten einer Lage koennen drei E-Mail-Benachrichtigungen einzeln aktiviert werden: "Neues Lagebild" (bzw. Recherchebericht) informiert nach einer Aktualisierung ueber die neue Zusammenfassung, "Neue Artikel" meldet gefundene Artikel und "Statusaenderung Faktencheck" meldet, wenn sich der Status einer geprueften Aussage aendert. Im Dashboard erscheinen neue Benachrichtigungen zusaetzlich als Badge am Glocken-Symbol.
Export:
Im Lage-Detail gibt es einen Export-Button. Der Markdown-Export erzeugt einen vollstaendigen Lagebericht als .md-Datei mit Zusammenfassung, Artikeln und Faktenchecks. Der JSON-Export liefert strukturierte Daten zur Weiterverarbeitung in anderen Systemen.
Im Lage-Detail gibt es einen Export-Button. Der Nutzer waehlt im Export-Dialog zunaechst aus, welche Bereiche enthalten sein sollen: "Zusammenfassung", "Recherchebericht / Lagebild", "Faktencheck" und "Quellen". Als Format stehen "PDF" und "Word (DOCX)" zur Verfuegung. Mit "Exportieren" wird die Datei erzeugt und heruntergeladen.
Sichtbarkeit:
Jede Lage kann "oeffentlich" oder "privat" sein. Oeffentliche Lagen sind fuer alle Nutzer der Organisation sichtbar. Private Lagen kann nur der Ersteller sehen und bearbeiten. Die Sichtbarkeit laesst sich ueber das Einstellungs-Menue der jeweiligen Lage aendern.
@@ -326,8 +346,8 @@ Jede Lage kann "oeffentlich" oder "privat" sein. Oeffentliche Lagen sind fuer al
Retention (Aufbewahrung):
Standardmaessig werden Lagen unbegrenzt aufbewahrt. Es kann aber eine Aufbewahrungsdauer in Tagen eingestellt werden. Nach Ablauf wird die Lage automatisch archiviert. Archivierte Lagen bleiben lesbar, werden aber nicht mehr automatisch aktualisiert.
Kartenansicht (Geoparsing):
Artikel werden automatisch auf geografische Erwahnungen analysiert. Erkannte Orte erscheinen auf einer interaktiven Karte mit farbigen Markern. Die Farben zeigen die Relevanz: Rot fuer Hauptgeschehen, Orange fuer Reaktionen, Blau fuer Beteiligte und Grau fuer erwaehnte Orte. Bei vielen Markern werden diese zu Clustern zusammengefasst. Ein Klick auf einen Marker zeigt die zugehoerigen Artikel. Die Karte hat einen Vollbildmodus und die Kategorien lassen sich ueber Checkboxen in der Legende ein- und ausblenden.
Kartenansicht:
In der Karten-Kachel erscheinen alle zur Lage erkannten Orte als farbige Marker. Die Farben zeigen die Relevanz: Rot fuer Hauptgeschehen, Orange fuer Reaktionen, Blau fuer Beteiligte und Grau fuer erwaehnte Orte. Bei vielen Markern werden diese zu Clustern zusammengefasst, ein Klick auf einen Marker oeffnet die zugehoerigen Artikel. Ueber das Vollbild-Symbol laesst sich die Karte grossformatig anzeigen, die Kategorien koennen ueber Checkboxen in der Legende ein- und ausgeblendet werden.
Quellenausschluss:
Bestimmte Domains koennen ueber die Quellen-Einstellungen blockiert werden. Blockierte Quellen tauchen dann in keiner Lage mehr auf. So lassen sich unerwuenschte oder unzuverlaessige Quellen dauerhaft ausschliessen.
@@ -395,7 +415,8 @@ def _build_prompt(user_message: str, history: list[dict]) -> str:
@router.post("", response_model=ChatResponse)
async def chat(
req: ChatRequest,
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Chat-Nachricht verarbeiten und Antwort generieren."""
user_id = current_user["id"]
@@ -420,15 +441,23 @@ async def chat(
# Claude CLI aufrufen
try:
result, duration_ms = await _call_claude_chat(prompt)
result, duration_ms, usage = await _call_claude_chat(prompt)
except TimeoutError:
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
except RuntimeError as e:
error_str = str(e)
if "rate_limit" in error_str:
except ClaudeCliError as e:
if e.error_type == "rate_limit":
raise HTTPException(status_code=429, detail="Der Assistent ist gerade ausgelastet. Bitte versuche es in einer Minute erneut.")
logger.error(f"Chat Claude-Fehler: {e}")
if e.error_type == "auth_error":
raise HTTPException(status_code=503, detail="KI-Zugang aktuell nicht verfuegbar. Bitte Administrator kontaktieren.")
logger.error(f"Chat Claude-Fehler [{e.error_type}]: {e}")
raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.")
except RuntimeError as e:
logger.error(f"Chat Claude-Fehler (unspezifisch): {e}")
raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.")
# Credits buchen
await charge_usage_to_tenant(db, current_user.get("tenant_id"), usage, source="chat")
await db.commit()
# Output sanitieren
reply = _sanitize_output(result)

Datei anzeigen

@@ -1,7 +1,7 @@
"""Incidents-Router: Lagen verwalten (Multi-Tenant)."""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse, DescriptionEnhanceRequest
from models import IncidentCreate, IncidentUpdate, IncidentResponse, IncidentListItem, SubscriptionUpdate, SubscriptionResponse, DescriptionEnhanceRequest
from auth import get_current_user
from middleware.license_check import require_writable_license
from database import db_dependency, get_db
@@ -69,17 +69,30 @@ async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict
return incident
@router.get("", response_model=list[IncidentResponse])
@router.get("", response_model=list[IncidentListItem])
async def list_incidents(
status_filter: str = None,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Lagen des Tenants auflisten (oeffentliche + eigene private)."""
"""Alle Lagen des Tenants auflisten (oeffentliche + eigene private).
Liefert schlanke Sidebar-Items — ohne summary, description, sources_json.
Volltexte kommen erst beim Oeffnen der Lage per GET /incidents/{id}.
"""
tenant_id = current_user.get("tenant_id")
user_id = current_user["id"]
query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
# Nur die fuer Sidebar + Edit-Dialog noetigen Spalten selektieren
# (spart bei Iran: 324 KB sources_json + 32 KB summary).
# has_summary als Bit — Frontend nutzt es zur Erkennung "erster Refresh".
query = (
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
"refresh_start_time, retention_days, visibility, "
"international_sources, include_telegram, created_by, created_at, updated_at, "
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
)
params = [tenant_id, user_id]
if status_filter:
@@ -155,43 +168,60 @@ async def get_refreshing_incidents(
from agents.orchestrator import orchestrator
queued_ids = list(orchestrator._queued_ids) if hasattr(orchestrator, '_queued_ids') else []
current_task = orchestrator._current_task if hasattr(orchestrator, '_current_task') else None
# Session-Start des aktuell laufenden Tasks — stabil ueber Multi-Pass/Retry hinweg.
# Verhindert, dass der Frontend-Timer beim Reload auf den letzten Log-Eintrag
# (pass 2/3 oder retry n) zurueckspringt.
current_started_at = (
orchestrator._current_task_started_at
if hasattr(orchestrator, '_current_task_started_at') else None
)
details = {}
for row in rows:
iid = row["incident_id"]
started_at = (
current_started_at
if (iid == current_task and current_started_at)
else row["started_at"]
)
details[str(iid)] = {"started_at": started_at}
return {
"refreshing": [row["incident_id"] for row in rows],
"queued": queued_ids,
"current": current_task,
"details": {str(row["incident_id"]): {"started_at": row["started_at"]} for row in rows},
"details": details,
}
# --- Beschreibung generieren (Prompt Enhancement) ---
ENHANCE_PROMPT_RESEARCH = """Du bist ein Recherche-Planer in einem OSINT-Lagemonitoring-System.
Deine Aufgabe: Strukturiere ein Recherche-Briefing, das Analysten als Leitfaden fuer ihre Suche verwenden.
Deine Aufgabe: Strukturiere ein Recherche-Briefing, das Analysten als Leitfaden für ihre Suche verwenden.
Du behauptest KEINE Fakten und musst das Thema NICHT kennen oder verifizieren.
Der Nutzer gibt das Thema vor -- du definierst Suchrichtungen, Schwerpunkte und Stichworte.
Erstelle das Briefing IMMER, auch wenn dir das Thema unbekannt ist.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ae, oe, ue, ss) und KEINE Umschreibungen.
WICHTIG: Verwende IMMER echte Umlaute (ä, ö, ü, ß) und KEINE Umschreibungen.
Titel: {title}
Vorhandener Kontext: {context}
Typ: Hintergrundrecherche
Erstelle ein praezises Recherche-Briefing mit:
1. Fallbezeichnung (vollstaendige Benennung des Themas basierend auf Titel und Kontext)
2. Recherche-Schwerpunkte (5-8 thematische Punkte, z.B. Sachverhalt, beteiligte Parteien, rechtliche Aspekte, mediale Rezeption, Hintergruende, Chronologie)
3. Relevante Suchbegriffe (deutsch + englisch, inkl. Abkuerzungen und alternative Schreibweisen)
Erstelle ein präzises Recherche-Briefing mit:
1. Fallbezeichnung (vollständige Benennung des Themas basierend auf Titel und Kontext)
2. Recherche-Schwerpunkte (5-8 thematische Punkte, z.B. Sachverhalt, beteiligte Parteien, rechtliche Aspekte, mediale Rezeption, Hintergründe, Chronologie)
3. Relevante Suchbegriffe (deutsch + englisch, inkl. Abkürzungen und alternative Schreibweisen)
Schreibe NUR das Briefing als Fliesstext mit Aufzaehlungen. Keine Erklaerungen, Rueckfragen oder Disclaimer."""
Schreibe NUR das Briefing als Fließtext mit Aufzählungen. Keine Erklärungen, Rückfragen oder Disclaimer."""
ENHANCE_PROMPT_ADHOC = """Du bist ein Recherche-Planer in einem OSINT-Lagemonitoring-System.
Deine Aufgabe: Erstelle eine knappe Vorfallsbeschreibung, die als Suchauftrag fuer Live-Monitoring dient.
Deine Aufgabe: Erstelle eine knappe Vorfallsbeschreibung, die als Suchauftrag für Live-Monitoring dient.
Du behauptest KEINE Fakten und musst den Vorfall NICHT kennen oder verifizieren.
Der Nutzer gibt das Thema vor -- du strukturierst, wonach gesucht werden soll.
Erstelle die Beschreibung IMMER, auch wenn dir der Vorfall unbekannt ist.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ae, oe, ue, ss) und KEINE Umschreibungen.
WICHTIG: Verwende IMMER echte Umlaute (ä, ö, ü, ß) und KEINE Umschreibungen.
Titel: {title}
Vorhandener Kontext: {context}
@@ -200,10 +230,10 @@ Typ: Live-Monitoring (aktuelle Ereignisse)
Erstelle eine knappe, informative Beschreibung mit:
1. Was ist passiert / worum geht es (basierend auf Titel und Kontext)
2. Wo (geographischer Kontext, falls ableitbar)
3. Wer ist beteiligt (Akteure, Organisationen, Laender)
4. Wonach soll gesucht werden (aktuelle Entwicklungen, Reaktionen, Hintergruende)
3. Wer ist beteiligt (Akteure, Organisationen, Länder)
4. Wonach soll gesucht werden (aktuelle Entwicklungen, Reaktionen, Hintergründe)
Schreibe NUR die Beschreibung als Fliesstext (3-5 Zeilen). Keine Erklaerungen, Rueckfragen oder Disclaimer."""
Schreibe NUR die Beschreibung als Fließtext (3-5 Zeilen). Keine Erklärungen, Rückfragen oder Disclaimer."""
_enhance_logger = logging.getLogger("osint.enhance")
@@ -211,27 +241,44 @@ _enhance_logger = logging.getLogger("osint.enhance")
@router.post("/enhance-description")
async def enhance_description(
data: DescriptionEnhanceRequest,
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Generiert eine strukturierte Beschreibung per KI aus dem Titel."""
from agents.claude_client import call_claude
from agents.claude_client import call_claude, ClaudeCliError
from config import CLAUDE_MODEL_FAST
from services.license_service import charge_usage_to_tenant
template = ENHANCE_PROMPT_RESEARCH if data.type == "research" else ENHANCE_PROMPT_ADHOC
context = data.description.strip() if data.description and data.description.strip() else "Kein Kontext angegeben"
prompt = template.format(title=data.title.strip(), context=context)
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST, raw_text=True)
_enhance_logger.info(
f"Beschreibung generiert fuer \"{data.title[:50]}\": "
f"{usage.input_tokens}in/{usage.output_tokens}out"
)
return {"description": result.strip()}
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST, raw_text=True, timeout=60)
except ClaudeCliError as e:
_enhance_logger.error(f"Beschreibung generieren: ClaudeCliError [{e.error_type}]: {e.message}")
if e.error_type == "auth_error":
raise HTTPException(status_code=503, detail="KI-Zugang aktuell nicht verfuegbar. Bitte Administrator kontaktieren.")
if e.error_type == "rate_limit":
raise HTTPException(status_code=429, detail="KI ist gerade ausgelastet. Bitte in einer Minute erneut versuchen.")
raise HTTPException(status_code=500, detail="Beschreibung konnte nicht generiert werden")
except TimeoutError:
_enhance_logger.error("Beschreibung generieren: Timeout")
raise HTTPException(status_code=504, detail="Die KI antwortet gerade nicht. Bitte erneut versuchen.")
except HTTPException:
raise
except Exception as e:
_enhance_logger.error(f"Beschreibung generieren fehlgeschlagen: {e}")
raise HTTPException(status_code=500, detail="Beschreibung konnte nicht generiert werden")
_enhance_logger.info(
f"Beschreibung generiert fuer \"{data.title[:50]}\": "
f"{usage.input_tokens}in/{usage.output_tokens}out"
)
await charge_usage_to_tenant(db, current_user.get("tenant_id"), usage, source="enhance")
await db.commit()
return {"description": result.strip()}
@router.get("/{incident_id}", response_model=IncidentResponse)
async def get_incident(
@@ -239,12 +286,41 @@ async def get_incident(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Einzelne Lage abrufen."""
"""Einzelne Lage abrufen.
sources_json wird NICHT mitgeliefert — fuer Zitate-Lookups
stattdessen GET /incidents/{id}/sources verwenden (lazy).
"""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
return await _enrich_incident(db, row)
@router.get("/{incident_id}/sources")
async def get_incident_sources(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Sources-Array einer Lage (geparst aus sources_json) fuer Zitate-Lookups."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT sources_json FROM incidents WHERE id = ?",
(incident_id,),
)
row = await cursor.fetchone()
sources: list = []
if row and row["sources_json"]:
try:
parsed = json.loads(row["sources_json"])
if isinstance(parsed, list):
sources = parsed
except (json.JSONDecodeError, TypeError):
sources = []
return {"incident_id": incident_id, "sources": sources}
@router.put("/{incident_id}", response_model=IncidentResponse)
async def update_incident(
incident_id: int,
@@ -317,18 +393,133 @@ async def delete_incident(
@router.get("/{incident_id}/articles")
async def get_articles(
incident_id: int,
limit: int = Query(500, ge=1, le=1000),
offset: int = Query(0, ge=0),
search: str | None = Query(None, min_length=0, max_length=200),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Artikel einer Lage abrufen."""
"""Artikel einer Lage paginiert abrufen.
Response: ``{"total": int, "articles": [...]}``.
Optionaler ``search``-Param filtert per LIKE ueber
headline, headline_de, source, content_de, content_original.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
search_clean = (search or "").strip()
if search_clean:
like = f"%{search_clean}%"
params = (incident_id, like, like, like, like, like)
where = (
"WHERE incident_id = ? AND ("
"COALESCE(headline,'') LIKE ? OR "
"COALESCE(headline_de,'') LIKE ? OR "
"COALESCE(source,'') LIKE ? OR "
"COALESCE(content_de,'') LIKE ? OR "
"COALESCE(content_original,'') LIKE ?)"
)
else:
params = (incident_id,)
where = "WHERE incident_id = ?"
cursor = await db.execute(f"SELECT COUNT(*) AS cnt FROM articles {where}", params)
total = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
f"SELECT * FROM articles {where} ORDER BY collected_at DESC LIMIT ? OFFSET ?",
(*params, limit, offset),
)
rows = await cursor.fetchall()
return {"total": total, "articles": [dict(row) for row in rows]}
@router.get("/{incident_id}/articles/sources-summary")
async def get_articles_sources_summary(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Quellen-Statistik fuer eine Lage (fuer Quellenuebersicht)."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
"""SELECT source,
COUNT(*) AS article_count,
GROUP_CONCAT(DISTINCT COALESCE(language,'de')) AS languages
FROM articles WHERE incident_id = ?
GROUP BY source ORDER BY article_count DESC""",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
sources = []
for r in await cursor.fetchall():
d = dict(r)
langs = (d.pop("languages") or "de").split(",")
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
sources.append(d)
# Sprach-Verteilung gesamt
cursor = await db.execute(
"""SELECT COALESCE(language,'de') AS language, COUNT(*) AS cnt
FROM articles WHERE incident_id = ?
GROUP BY language ORDER BY cnt DESC""",
(incident_id,),
)
lang_counts = [dict(r) for r in await cursor.fetchall()]
total_cursor = await db.execute(
"SELECT COUNT(*) AS cnt FROM articles WHERE incident_id = ?",
(incident_id,),
)
total = (await total_cursor.fetchone())["cnt"]
return {"total": total, "sources": sources, "language_counts": lang_counts}
@router.get("/{incident_id}/articles/timeline-buckets")
async def get_articles_timeline_buckets(
incident_id: int,
granularity: str = Query("day", pattern="^(hour|day|week|month)$"),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Zeit-Buckets fuer die Timeline-Achse.
Zaehlt Artikel und Snapshots pro Bucket. Kein Inhalt, nur Counts.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
fmt_map = {
"hour": "%Y-%m-%d %H:00",
"day": "%Y-%m-%d",
"week": "%Y-%W",
"month": "%Y-%m",
}
fmt = fmt_map[granularity]
cursor = await db.execute(
f"""SELECT strftime(?, collected_at) AS bucket, COUNT(*) AS article_count
FROM articles WHERE incident_id = ?
GROUP BY bucket ORDER BY bucket""",
(fmt, incident_id),
)
article_rows = {r["bucket"]: r["article_count"] for r in await cursor.fetchall()}
cursor = await db.execute(
f"""SELECT strftime(?, created_at) AS bucket, COUNT(*) AS snapshot_count
FROM incident_snapshots WHERE incident_id = ?
GROUP BY bucket ORDER BY bucket""",
(fmt, incident_id),
)
snapshot_rows = {r["bucket"]: r["snapshot_count"] for r in await cursor.fetchall()}
all_buckets = sorted(set(article_rows.keys()) | set(snapshot_rows.keys()))
return {
"granularity": granularity,
"buckets": [
{
"bucket": b,
"article_count": article_rows.get(b, 0),
"snapshot_count": snapshot_rows.get(b, 0),
}
for b in all_buckets
],
}
@router.get("/{incident_id}/snapshots")
@@ -337,12 +528,17 @@ async def get_snapshots(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lageberichte (Snapshots) einer Lage abrufen."""
"""Lageberichte (Snapshots) einer Lage abrufen — schlanke Liste.
Liefert nur Metadaten und einen 300-Zeichen-Preview des Summary.
Der Volltext (summary + sources_json) wird per Einzel-Endpunkt
``GET /{incident_id}/snapshots/{snapshot_id}`` bei Bedarf geladen.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, incident_id, summary, sources_json,
article_count, fact_check_count, created_at
"""SELECT id, incident_id, article_count, fact_check_count, created_at,
SUBSTR(summary, 1, 300) AS summary_preview
FROM incident_snapshots WHERE incident_id = ?
ORDER BY created_at DESC""",
(incident_id,),
@@ -351,6 +547,55 @@ async def get_snapshots(
return [dict(row) for row in rows]
@router.get("/{incident_id}/snapshots/search")
async def search_snapshots(
incident_id: int,
q: str = Query(..., min_length=2, max_length=200),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Volltextsuche über alle Snapshots einer Lage.
Liefert dieselbe schlanke Shape wie der Listen-Endpunkt,
gefiltert per ``summary LIKE '%q%'``.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
like = f"%{q}%"
cursor = await db.execute(
"""SELECT id, incident_id, article_count, fact_check_count, created_at,
SUBSTR(summary, 1, 300) AS summary_preview
FROM incident_snapshots
WHERE incident_id = ? AND summary LIKE ?
ORDER BY created_at DESC""",
(incident_id, like),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/snapshots/{snapshot_id}")
async def get_snapshot(
incident_id: int,
snapshot_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Einzelnen Snapshot mit vollem Summary + sources_json abrufen (Lazy-Load)."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, incident_id, summary, sources_json,
article_count, fact_check_count, created_at
FROM incident_snapshots WHERE id = ? AND incident_id = ?""",
(snapshot_id, incident_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Snapshot nicht gefunden")
return dict(row)
@router.get("/{incident_id}/factchecks")
async def get_factchecks(
incident_id: int,
@@ -374,60 +619,100 @@ async def get_locations(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Geografische Orte einer Lage abrufen (aggregiert nach Ort)."""
"""Geografische Orte einer Lage abrufen (serverseitig aggregiert nach Ort).
Drei getrennte Queries (alle klein) statt eines 21k-Zeilen-JOINs:
1. Orte-Aggregate per GROUP BY (name, lat, lon) — liefert direkt ~Ergebnismenge.
2. Kategorien pro Ort per GROUP BY (name, lat, lon, category) — fuer dominante Kategorie.
3. Sample-Artikel pro Ort via ROW_NUMBER() — max. 10 pro Ort.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
# 1. Orte-Aggregate
cursor = await db.execute(
"""SELECT al.location_name, al.location_name_normalized, al.country_code,
al.latitude, al.longitude, al.confidence, al.category,
a.id as article_id, a.headline, a.headline_de, a.source, a.source_url
FROM article_locations al
JOIN articles a ON a.id = al.article_id
WHERE al.incident_id = ?
ORDER BY al.location_name_normalized, a.collected_at DESC""",
"""SELECT
COALESCE(location_name_normalized, location_name) AS name,
ROUND(latitude, 2) AS lat,
ROUND(longitude, 2) AS lon,
MIN(country_code) AS country_code,
MAX(confidence) AS confidence,
COUNT(*) AS article_count
FROM article_locations
WHERE incident_id = ?
GROUP BY name, lat, lon
ORDER BY article_count DESC""",
(incident_id,),
)
rows = await cursor.fetchall()
loc_rows = [dict(r) for r in await cursor.fetchall()]
# Aggregierung nach normalisiertem Ortsnamen + Koordinaten
loc_map = {}
for row in rows:
row = dict(row)
key = (row["location_name_normalized"] or row["location_name"], round(row["latitude"], 2), round(row["longitude"], 2))
if key not in loc_map:
loc_map[key] = {
"location_name": row["location_name_normalized"] or row["location_name"],
"lat": row["latitude"],
"lon": row["longitude"],
"country_code": row["country_code"],
"confidence": row["confidence"],
"article_count": 0,
"articles": [],
"categories": {},
}
loc_map[key]["article_count"] += 1
cat = row["category"] or "mentioned"
loc_map[key]["categories"][cat] = loc_map[key]["categories"].get(cat, 0) + 1
# Maximal 10 Artikel pro Ort mitliefern
if len(loc_map[key]["articles"]) < 10:
loc_map[key]["articles"].append({
"id": row["article_id"],
"headline": row["headline_de"] or row["headline"],
"source": row["source"],
"source_url": row["source_url"],
})
# 2. Kategorien pro Ort
cursor = await db.execute(
"""SELECT
COALESCE(location_name_normalized, location_name) AS name,
ROUND(latitude, 2) AS lat,
ROUND(longitude, 2) AS lon,
COALESCE(category, 'mentioned') AS category,
COUNT(*) AS cnt
FROM article_locations
WHERE incident_id = ?
GROUP BY name, lat, lon, category""",
(incident_id,),
)
cat_map: dict[tuple, dict[str, int]] = {}
for r in await cursor.fetchall():
key = (r["name"], r["lat"], r["lon"])
cat_map.setdefault(key, {})[r["category"]] = r["cnt"]
# Dominanteste Kategorie pro Ort bestimmen (Prioritaet: primary > secondary > tertiary > mentioned)
# 3. Sample-Artikel pro Ort (max. 10, neueste zuerst)
cursor = await db.execute(
"""SELECT name, lat, lon, article_id, headline, headline_de, source, source_url
FROM (
SELECT
COALESCE(al.location_name_normalized, al.location_name) AS name,
ROUND(al.latitude, 2) AS lat,
ROUND(al.longitude, 2) AS lon,
a.id AS article_id,
a.headline, a.headline_de, a.source, a.source_url,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(al.location_name_normalized, al.location_name),
ROUND(al.latitude, 2), ROUND(al.longitude, 2)
ORDER BY a.collected_at DESC
) AS rn
FROM article_locations al
JOIN articles a ON a.id = al.article_id
WHERE al.incident_id = ?
)
WHERE rn <= 10""",
(incident_id,),
)
sample_map: dict[tuple, list[dict]] = {}
for r in await cursor.fetchall():
key = (r["name"], r["lat"], r["lon"])
sample_map.setdefault(key, []).append({
"id": r["article_id"],
"headline": r["headline_de"] or r["headline"],
"source": r["source"],
"source_url": r["source_url"],
})
# Zusammensetzen
priority = {"primary": 4, "secondary": 3, "tertiary": 2, "mentioned": 1}
result = []
for loc in loc_map.values():
cats = loc.pop("categories")
if cats:
best_cat = max(cats, key=lambda c: (priority.get(c, 0), cats[c]))
else:
best_cat = "mentioned"
loc["category"] = best_cat
result.append(loc)
for loc in loc_rows:
key = (loc["name"], loc["lat"], loc["lon"])
cats = cat_map.get(key, {})
best_cat = max(cats, key=lambda c: (priority.get(c, 0), cats[c])) if cats else "mentioned"
result.append({
"location_name": loc["name"],
"lat": loc["lat"],
"lon": loc["lon"],
"country_code": loc["country_code"],
"confidence": loc["confidence"],
"article_count": loc["article_count"],
"articles": sample_map.get(key, []),
"category": best_cat,
})
# Category-Labels aus Incident laden
cursor = await db.execute(
@@ -737,6 +1022,34 @@ async def export_incident(
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Organisation (fuer Dateimetadaten)
organization_name = None
if incident.get("tenant_id"):
cursor = await db.execute(
"SELECT name FROM organizations WHERE id = ?", (incident["tenant_id"],)
)
org_row = await cursor.fetchone()
organization_name = org_row["name"] if org_row else None
# Top-Orte (fuer Keyword-Metadaten)
cursor = await db.execute(
"""SELECT location_name, COUNT(*) AS cnt
FROM article_locations
WHERE incident_id = ?
GROUP BY COALESCE(location_name_normalized, location_name)
ORDER BY cnt DESC
LIMIT 5""",
(incident_id,),
)
top_locations = [r["location_name"] for r in await cursor.fetchall() if r["location_name"]]
# Snapshot-Count (als xmpMM:VersionID im PDF)
cursor = await db.execute(
"SELECT COUNT(*) AS cnt FROM incident_snapshots WHERE incident_id = ?",
(incident_id,),
)
snapshot_count = (await cursor.fetchone())["cnt"] or 0
# Artikel
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
@@ -786,7 +1099,13 @@ async def export_incident(
scope_labels_key = scope_labels.get(scope, "lagebericht")
if format == "pdf":
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, creator, exec_summary, sections=sections_set)
pdf_bytes = await generate_pdf(
incident, articles, fact_checks, snapshots, scope, creator, exec_summary,
sections=sections_set,
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
)
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
@@ -794,7 +1113,13 @@ async def export_incident(
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else:
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, creator, exec_summary, sections=sections_set)
docx_bytes = await generate_docx(
incident, articles, fact_checks, snapshots, scope, creator, exec_summary,
sections=sections_set,
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
)
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
return StreamingResponse(
io.BytesIO(docx_bytes),

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

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

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

Datei anzeigen

@@ -91,6 +91,92 @@ async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[
return True, ""
async def charge_usage_to_tenant(
db: aiosqlite.Connection,
tenant_id: int | None,
usage,
source: str,
) -> None:
"""Verbucht Token-Verbrauch auf einen Tenant.
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source)
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt).
Args:
db: offene aiosqlite.Connection
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
source: 'monitor' | 'enhance' | 'chat'
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht.
"""
total_cost = getattr(usage, "total_cost_usd", None)
if total_cost is None:
total_cost = getattr(usage, "cost_usd", 0.0)
if not tenant_id:
logger.info(
f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen "
f"(cost=${total_cost:.4f})"
)
return
if total_cost <= 0:
return
input_tokens = getattr(usage, "input_tokens", 0)
output_tokens = getattr(usage, "output_tokens", 0)
cache_creation = getattr(usage, "cache_creation_tokens", 0)
cache_read = getattr(usage, "cache_read_tokens", 0)
api_calls = getattr(usage, "call_count", 1)
refresh_increment = 1 if source == "monitor" else 0
year_month = datetime.now(TIMEZONE).strftime("%Y-%m")
await db.execute(
"""
INSERT INTO token_usage_monthly
(organization_id, year_month, source, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(organization_id, year_month, source) DO UPDATE SET
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
api_calls = api_calls + excluded.api_calls,
refresh_count = refresh_count + excluded.refresh_count,
updated_at = CURRENT_TIMESTAMP
""",
(
tenant_id, year_month, source,
input_tokens, output_tokens, cache_creation, cache_read,
round(total_cost, 7), api_calls, refresh_increment,
),
)
lic_cursor = await db.execute(
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(tenant_id,),
)
lic = await lic_cursor.fetchone()
credits_consumed = 0.0
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
credits_consumed = total_cost / lic["cost_per_credit"]
await db.execute(
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
(round(credits_consumed, 2), tenant_id),
)
logger.info(
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: "
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits"
)
async def expire_licenses(db: aiosqlite.Connection):
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
cursor = await db.execute(

Datei anzeigen

@@ -120,7 +120,9 @@
<div class="sidebar-sources-link">
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
-->
<div class="sidebar-stats-mini">
<span id="stat-sources-count">0 Quellen</span> &middot; <span id="stat-articles-count">0 Artikel</span>
</div>
@@ -622,14 +624,14 @@
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/api.js?v=20260316c"></script>
<script src="/static/js/api.js?v=20260423a"></script>
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260316d"></script>
<script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/app.js?v=20260316b"></script>
<script src="/static/js/app.js?v=20260423a"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260316i"></script>
<script src="/static/js/chat.js?v=20260422a"></script>
<script>document.addEventListener("DOMContentLoaded",function(){Chat.init();Tutorial.init()});</script>
<!-- Map Fullscreen Overlay -->

Datei anzeigen

@@ -1,6 +1,16 @@
/**
* API-Client für den OSINT Lagemonitor.
*/
class ApiError extends Error {
constructor(status, detail) {
super(detail || `Fehler ${status}`);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
const API = {
baseUrl: '/api',
@@ -57,7 +67,7 @@ const API = {
} else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail);
}
throw new Error(detail || `Fehler ${response.status}`);
throw new ApiError(response.status, detail);
}
if (response.status === 204) return null;
@@ -91,6 +101,10 @@ const API = {
return this._request('GET', `/incidents/${id}`);
},
getIncidentSources(id) {
return this._request('GET', `/incidents/${id}/sources`);
},
updateIncident(id, data) {
return this._request('PUT', `/incidents/${id}`, data);
},
@@ -99,8 +113,20 @@ const API = {
return this._request('DELETE', `/incidents/${id}`);
},
getArticles(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles`);
getArticles(incidentId, { limit = 500, offset = 0, search = null } = {}) {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
if (search) params.set('search', search);
return this._request('GET', `/incidents/${incidentId}/articles?${params.toString()}`);
},
getArticlesSourcesSummary(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles/sources-summary`);
},
getArticlesTimelineBuckets(incidentId, granularity = 'day') {
return this._request('GET', `/incidents/${incidentId}/articles/timeline-buckets?granularity=${encodeURIComponent(granularity)}`);
},
getFactChecks(incidentId) {
@@ -111,6 +137,14 @@ const API = {
return this._request('GET', `/incidents/${incidentId}/snapshots`);
},
getSnapshot(incidentId, snapshotId) {
return this._request('GET', `/incidents/${incidentId}/snapshots/${snapshotId}`);
},
searchSnapshots(incidentId, query) {
return this._request('GET', `/incidents/${incidentId}/snapshots/search?q=${encodeURIComponent(query)}`);
},
getLocations(incidentId) {
return this._request('GET', `/incidents/${incidentId}/locations`);
},

Datei anzeigen

@@ -420,6 +420,9 @@ const App = {
_refreshingIncidents: new Set(),
_editingIncidentId: null,
_currentArticles: [],
_currentSnapshots: [],
_snapshotFullCache: new Map(),
_currentSources: [],
_currentIncidentType: 'adhoc',
_sidebarFilter: 'all',
_currentUsername: '',
@@ -584,7 +587,7 @@ const App = {
this._refreshingIncidents.add(id);
const d = details[String(id)] || {};
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.summary;
const isFirst = inc && !inc.has_summary;
const isCurrent = (id === currentTask);
// Use 'researching' as default step for the actively running task
UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst);
@@ -596,7 +599,7 @@ const App = {
queuedIds.forEach((id, idx) => {
this._refreshingIncidents.add(id);
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.summary;
const isFirst = inc && !inc.has_summary;
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
});
}
@@ -785,15 +788,29 @@ const App = {
async loadIncidentDetail(id) {
try {
const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([
API.getIncident(id),
API.getArticles(id),
API.getArticles(id, { limit: 500, offset: 0 }),
API.getFactChecks(id),
API.getSnapshots(id),
API.getLocations(id).catch(() => []),
API.getIncidentSources(id).catch(() => ({ sources: [] })),
]);
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel)
// Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt)
this._currentSources = (sourcesResponse && sourcesResponse.sources) || [];
// Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
let articles, articlesTotal;
if (Array.isArray(articlesResponse)) {
articles = articlesResponse;
articlesTotal = articlesResponse.length;
} else {
articles = articlesResponse.articles || [];
articlesTotal = articlesResponse.total || articles.length;
}
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
let locations, categoryLabels;
if (Array.isArray(locationsResponse)) {
locations = locationsResponse;
@@ -806,13 +823,63 @@ const App = {
categoryLabels = null;
}
this._currentArticlesTotal = articlesTotal;
this._currentArticlesLoaded = articles.length;
this._currentIncidentIdForLoad = id;
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
// Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
if (articlesTotal > articles.length) {
this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
}
} catch (err) {
console.error('loadIncidentDetail Fehler:', err);
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
}
},
/** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
async _loadSourcesSummary(incidentId) {
const data = await API.getArticlesSourcesSummary(incidentId);
if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
this._currentSourcesSummary = data;
const soEl = document.getElementById('source-overview-content');
const statsEl = document.getElementById('source-overview-header-stats');
if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
}
if (statsEl && data) {
statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
}
},
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
async _loadRemainingArticlesInBackground(incidentId) {
const BATCH = 500;
while (this.currentIncidentId === incidentId
&& this._currentArticlesLoaded < this._currentArticlesTotal) {
let resp;
try {
resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
} catch (err) {
console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
return;
}
if (this.currentIncidentId !== incidentId) return;
const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
if (!batch.length) break;
this._currentArticles = (this._currentArticles || []).concat(batch);
this._currentArticlesLoaded += batch.length;
this.rerenderTimeline();
// Kleiner Yield, damit das UI reaktiv bleibt
await new Promise(r => setTimeout(r, 30));
}
},
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
// Header Strip
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
@@ -859,13 +926,13 @@ const App = {
if (incident.summary) {
const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary);
if (zusammenfassung) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, incident.sources_json);
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources);
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(remaining, incident.sources_json, incident.type);
summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type);
} else {
if (zusammenfassungText) zusammenfassungText.innerHTML = '<span style="color:var(--text-disabled);">Zusammenfassung wird beim n\u00e4chsten Refresh generiert.</span>';
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(incident.summary, incident.sources_json, incident.type);
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
}
} else {
if (zusammenfassungCard) zusammenfassungCard.style.display = 'none';
@@ -877,12 +944,12 @@ const App = {
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
const devText = (incident.latest_developments || '').trim();
if (devText) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, incident.sources_json);
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources);
} else if (zusammenfassungText) {
zusammenfassungText.innerHTML = '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert.</span>';
}
if (incident.summary) {
summaryText.innerHTML = UI.renderSummary(incident.summary, incident.sources_json, incident.type);
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
} else {
summaryText.innerHTML = '<span style="color:var(--text-disabled);">Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten.</span>';
}
@@ -938,22 +1005,22 @@ const App = {
factcheckList.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Noch keine Fakten geprüft</div>';
}
// Quellenübersicht
// Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
// damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
const sourceOverview = document.getElementById('source-overview-content');
if (sourceOverview) {
sourceOverview.innerHTML = UI.renderSourceOverview(articles);
// Stats im Header aktualisieren (sichtbar im zugeklappten Zustand)
const _soStats = document.getElementById("source-overview-header-stats");
if (_soStats) {
const _soSources = new Set(articles.map(a => a.source).filter(Boolean));
_soStats.textContent = articles.length + " Artikel aus " + _soSources.size + " Quellen";
}
// Im Tab-Modus wird die Kachel vom Seiten-Layout bestimmt — kein Resize noetig
sourceOverview.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Quellenübersicht wird geladen…</div>';
}
const _soStats = document.getElementById("source-overview-header-stats");
if (_soStats) {
const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
_soStats.textContent = total + " Artikel";
}
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
this._currentArticles = articles;
this._currentSnapshots = snapshots || [];
this._snapshotFullCache = new Map();
this._currentIncidentType = incident.type;
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
@@ -1001,7 +1068,9 @@ const App = {
if (filterType === 'all' || filterType === 'snapshots') {
let snapshots = this._currentSnapshots || [];
if (searchTerm) {
snapshots = snapshots.filter(s => (s.summary || '').toLowerCase().includes(searchTerm));
// Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
// Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
}
snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
}
@@ -1503,6 +1572,7 @@ const App = {
/**
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
* Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
*/
_renderSnapshotEntry(snapshot) {
const time = snapshot.created_at
@@ -1514,24 +1584,58 @@ const App = {
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
const statsText = stats.join(', ');
// Vorschau: erste 200 Zeichen der Zusammenfassung
const summaryText = snapshot.summary || '';
const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
// Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
const previewText = snapshot.summary_preview || snapshot.summary || '';
const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
// Vollständige Zusammenfassung via UI.renderSummary
const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
// Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
const detailHtml = cached
? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
: '<div class="vt-snapshot-loading">Lagebericht wird geladen…</div>';
const loadedAttr = cached ? ' data-loaded="yes"' : '';
return `<div class="vt-entry vt-snapshot expandable" onclick="App.toggleTimelineEntry(this)">
return `<div class="vt-entry vt-snapshot expandable" data-snapshot-id="${snapshot.id}"${loadedAttr} onclick="App.toggleTimelineEntry(this)">
<div class="vt-snapshot-header">
<span class="vt-snapshot-badge">Lagebericht</span>
<span class="vt-snapshot-time">${time}</span>
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
</div>
<div class="vt-snapshot-preview">${UI.escape(preview)}</div>
<div class="vt-snapshot-detail">${fullSummary}</div>
<div class="vt-snapshot-detail">${detailHtml}</div>
</div>`;
},
/**
* Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
* Ergebnis wird in _snapshotFullCache gecacht.
*/
async lazyLoadSnapshotDetail(el) {
if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
const snapId = parseInt(el.dataset.snapshotId || '0', 10);
if (!snapId || !this.currentIncidentId) return;
el.dataset.loaded = 'loading';
try {
let snap = this._snapshotFullCache.get(snapId);
if (!snap) {
snap = await API.getSnapshot(this.currentIncidentId, snapId);
this._snapshotFullCache.set(snapId, snap);
}
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) {
detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
}
el.dataset.loaded = 'yes';
// Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
if (el.classList.contains('expanded')) this._resizeTimelineTile();
} catch (err) {
console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
el.dataset.loaded = '';
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) detailEl.innerHTML = '<div class="vt-snapshot-error">Fehler beim Laden des Lageberichts.</div>';
}
},
/**
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
*/
@@ -1544,6 +1648,10 @@ const App = {
}
el.classList.toggle('expanded');
if (el.classList.contains('expanded')) {
// Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
this.lazyLoadSnapshotDetail(el);
}
requestAnimationFrame(() => {
var scrollParent = el.closest('.ht-detail-content');
if (scrollParent && el.classList.contains('vt-snapshot')) {
@@ -1658,12 +1766,19 @@ const App = {
closeModal('modal-new');
await this.loadIncidents();
// Refresh-Status VOR selectIncident setzen, damit selectIncident
// beim Oeffnen sofort Blur + Aktions-Lock setzt (statt sie erst
// per WebSocket-Nachricht spaeter wieder zu aktivieren — dazwischen
// war der Fallinhalt kurzzeitig unblurred und klickbar).
this._refreshingIncidents.add(incident.id);
UI._progressState[incident.id] = {
step: 'queued', isFirst: true, startTime: null, minimized: false,
};
await this.selectIncident(incident.id);
// Sofort ersten Refresh starten
this._refreshingIncidents.add(incident.id);
this._updateRefreshButton(true);
// showProgress called via handleStatusUpdate
await API.refreshIncident(incident.id);
UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
}
@@ -1701,8 +1816,15 @@ async generateDescription() {
textarea.value = result.description;
_autoResizeTextarea(textarea);
} catch (err) {
if (err.name !== 'AbortError') {
UI.showToast('Beschreibung konnte nicht generiert werden', 'error');
if (err.name === 'AbortError') {
// still
} else {
let msg = 'Beschreibung konnte nicht generiert werden';
if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.';
else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.';
else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.';
else if (err.status === 403) msg = err.detail || 'Zugriff verweigert.';
UI.showToast(msg, 'error');
}
} finally {
btnText.textContent = 'Beschreibung generieren';
@@ -1730,7 +1852,7 @@ async handleRefresh() {
} else {
UI.showToast('Aktualisierung gestartet.', 'success');
var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.summary);
UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary);
}
} catch (err) {
this._refreshingIncidents.delete(this.currentIncidentId);
@@ -2073,7 +2195,7 @@ async handleRefresh() {
this._updateSidebarDot(msg.incident_id);
// Detect first refresh: no summary means first run
const inc = this.incidents.find(i => i.id === msg.incident_id);
const isFirst = inc && !inc.summary;
const isFirst = inc && !inc.has_summary;
// Update progress state for ALL incidents (sidebar + popup if current)
UI.showProgress(status, msg.data, msg.incident_id, isFirst);
// Re-render sidebar so status is baked into HTML (survives future re-renders)

Datei anzeigen

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

Datei anzeigen

@@ -709,13 +709,27 @@ const UI = {
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>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
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;
@@ -751,8 +765,7 @@ const UI = {
*/
renderLatestDevelopments(text, sourcesJson) {
if (!text) return '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst.</span>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
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) {
@@ -869,8 +882,7 @@ const UI = {
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
const sources = this._parseSources(sourcesJson);
// Markdown-Rendering
let html = this.escape(summary);
@@ -930,6 +942,37 @@ const UI = {
/**
* Quellenübersicht für eine Lage rendern.
*/
/**
* Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage,
* unabhaengig von Paginierung im Frontend).
* data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]}
*/
renderSourceOverviewFromSummary(data) {
if (!data || !data.sources || data.sources.length === 0) return '';
const langChips = (data.language_counts || [])
.map(l => `<span class="source-lang-chip">${(l.language || 'de').toUpperCase()} <strong>${l.cnt}</strong></span>`)
.join('');
let html = `<div class="source-overview-header">`;
html += `<span class="source-overview-stat">${data.total} Artikel aus ${data.sources.length} Quellen</span>`;
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
html += '<div class="source-overview-grid">';
data.sources.forEach(s => {
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
html += `<div class="source-overview-item">
<span class="source-overview-name">${this.escape(s.source || 'Unbekannt')}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${s.article_count}</span>
</div>`;
});
html += '</div>';
return html;
},
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';