11 Commits

Autor SHA1 Nachricht Datum
eb841e3d25 Promote develop → main (2026-07-26 02:18 UTC) 2026-07-26 04:18:44 +02:00
59cd97adac Release-Notes: Interne Verbesserungen 2026-07-26 04:18:41 +02:00
claude-dev
4c024cd470 docs: CLAUDE.md auf den echten Stand gebracht
Korrigiert: DB-Pfad (/home/claude-dev/osint-data, nicht /mnt/gitea),
Dienstname aegis-monitor.service, Ein-Prozess-Hinweis, Scheduler-Jobs
vollstaendig, Pipeline-Reihenfolge (Faktencheck VOR Lagebild),
Reiter-Ansicht statt gridstack, Tabellenliste vervollstaendigt
(keine feedback-Tabelle, dafuer refresh_pipeline_steps, incident_events,
fact_check_runs, billing_tariff, user_activity_days, system_status u.a.),
Mandantentrennungs-Aussage praezisiert, Staging-DB-Pfad + .env-Realitaet,
veralteter Workflow-Abschnitt ersetzt (Auto-Deploy laeuft laengst),
erledigte offene Punkte raus, stattdessen ehrliche Baustellen-Liste
Stand 26.07.2026, UI-Sync-Unterschiede in beide Richtungen aktualisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:10:04 +00:00
1a60aa1ba5 Promote develop → main (2026-07-25 23:31 UTC) 2026-07-26 01:31:20 +02:00
444241c7d3 Release-Notes: Abrechnung, Budget-Warnung & Telegram-Verbesserungen 2026-07-26 01:31:17 +02:00
claude-dev
f8499c4e40 feat(abrechnung): Credits-Uebertrag in den Folgemonat entfernt
Produktentscheidung 07/2026: ungenutzte Credits verfallen zum
Periodenende, es gilt der harte Monatsdeckel wie verkauft. Der
Uebertrag (credits_rollover/credits_carried) fliegt komplett raus:
roll_credit_period setzt nur noch Verbrauch + Warnflag zurueck,
check_license rechnet verfuegbar = credits_total, die Migration legt
die beiden Spalten gar nicht erst an (Live hat sie nie bekommen, die
Promote-Warteschlange enthielt sie noch). ABRECHNUNG.md angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 21:45:15 +00:00
claude-dev
5450fd25ae feat(system): Telefonnummer in der Telegram-Status-Meldung
Ergaenzt phone im telegram_session-Status, damit das Portal die
Telegram-Zeile im Reiter Recherche-Zugaenge vollstaendig fuellen kann.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:15:56 +00:00
claude-dev
7dae63ebf9 feat(system): Telegram-Session-Status in system_status melden
Neue Key-Value-Tabelle system_status (idempotente Migration). Der Monitor
prueft die Telethon-Session beim Start und taeglich um 04:30 (eigener
Scheduler-Job) und schreibt ok/account/error unter dem Key
telegram_session. Das Verwaltungsportal zeigt den Eintrag im Reiter
Recherche-Zugaenge an, damit ein Session-Ausfall sichtbar wird statt
nur im Server-Log zu stehen. Fehler beim Pruefen stoeren den Betrieb
nie (try/except, eigene DB-Connection wie beim Health-Nachtjob).

Promote-Reihenfolge wie gehabt: Monitor VOR Verwaltung (Migration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:04:46 +00:00
claude-dev
17b1886f25 fix(abrechnung): Warnschwelle 0 schaltet die Budget-Warnung aus
Bisher fiel eine 0 durch das or-80-Muster still auf die Voreinstellung
zurück, ein Abschalten der Warnung war unmöglich. Jetzt gilt, 0 = aus,
NULL = Voreinstellung 80, alles andere wie gehabt. Das Verwaltungsportal
bietet die 0 in seinen Formularen an.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:51:45 +00:00
claude-dev
d367c60b26 feat(statistik): Aktivitäts-Tage je Nutzer erfassen (MAU/DAU-Grundlage)
Neue Tabelle user_activity_days (user_id, day, tenant_id, ein Eintrag je
Nutzer und Kalendertag), angelegt per idempotenter Startup-Migration.
Geschrieben wird sie in get_current_user bei jeder authentifizierten
Nutzung, ein In-Memory-Tagesmerker begrenzt das auf einen INSERT OR
IGNORE je Nutzer und Tag, Fehler werden geschluckt und blockieren nie
eine Anfrage. Gelesen wird die Tabelle nur vom Verwaltungsportal für
die neue MAU/DAU-Statistik. Ein reines Login-Protokoll wäre zu grob,
weil das JWT 24 Stunden hält.

Getestet gegen eine DB-Kopie, Tabelle entsteht, je Nutzer und Tag
genau ein Eintrag trotz Mehrfachaufruf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:01:00 +00:00
claude-dev
027244ada5 feat(abrechnung): pflegbare Preistabelle billing_tariff als Quelle der Sätze
Die Credits-Sätze je Aktion stehen jetzt in der geteilten Tabelle
billing_tariff statt nur in der Konfiguration. Die Migration legt die
Tabelle an und befüllt sie einmalig mit den wirksamen CREDIT_TARIFF-
Werten, danach liest _charge_credits() bei jeder Buchung die Tabelle,
fehlende Schlüssel oder eine fehlende Tabelle fallen auf die
Konfiguration zurück. Damit können Monitor und Verwaltungsportal
dieselben Sätze anzeigen und das Portal kann sie künftig pflegen,
eine Preisänderung braucht keinen Server-Eingriff mehr.

Getestet gegen eine Kopie der Staging-DB. Seed mit 7 Sätzen (45/40/
12/12/1/1/1), Buchung nach Tabelle, Tabellenänderung greift sofort,
Rückfall auf die Konfiguration ohne Tabelle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:28:09 +00:00
7 geänderte Dateien mit 319 neuen und 127 gelöschten Zeilen

205
CLAUDE.md
Datei anzeigen

@@ -10,9 +10,9 @@ url: https://monitor.aegis-sight.de
server: ssh monitor (46.225.141.13, User: claude-dev) server: ssh monitor (46.225.141.13, User: claude-dev)
pfad: /home/claude-dev/AegisSight-Monitor pfad: /home/claude-dev/AegisSight-Monitor
quellcode: /home/claude-dev/AegisSight-Monitor/src/ quellcode: /home/claude-dev/AegisSight-Monitor/src/
datenbank: /mnt/gitea/osint-data/osint.db (SQLite WAL, geteilt mit Verwaltungsportal + Globe) datenbank: /home/claude-dev/osint-data/osint.db (SQLite WAL, geteilt mit Verwaltungsportal + Globe; data/ im Projekt ist ein Symlink darauf)
gitea: https://gitea-undso.aegis-sight.de/AegisSight/AegisSight-Monitor gitea: https://gitea-undso.aegis-sight.de/AegisSight/AegisSight-Monitor
service: osint-monitor.service (systemd, Port 8891, Nginx Reverse Proxy) service: aegis-monitor.service (systemd, Port 8891, Nginx Reverse Proxy, EIN uvicorn-Prozess - Orchestrator und WebSockets halten Zustand im Speicher, niemals --workers setzen)
venv: /home/claude-dev/.venvs/osint/ (Python 3.12) venv: /home/claude-dev/.venvs/osint/ (Python 3.12)
``` ```
@@ -23,21 +23,21 @@ backend:
framework: FastAPI + Uvicorn framework: FastAPI + Uvicorn
datenbank: SQLite WAL (aiosqlite, async) datenbank: SQLite WAL (aiosqlite, async)
auth: Magic-Link-Login per E-Mail (JWT HS256, 24h) auth: Magic-Link-Login per E-Mail (JWT HS256, 24h)
scheduler: APScheduler (Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00) scheduler: APScheduler (PDF-Ingest 1min, Auto-Refresh 1min, Cleanup 1h, Health-Check taeglich 04:00, Telegram-Status taeglich 04:30)
websocket: FastAPI native (Echtzeit-Updates an Clients) websocket: FastAPI native (Echtzeit-Updates an Clients)
ki: Claude CLI als Subprocess (WebSearch + WebFetch Tools) ki: Claude CLI als Subprocess (WebSearch + WebFetch Tools)
ki_modelle: ki_modelle:
schnell: CLAUDE_MODEL_FAST (Haiku) — Feed-Selektion, Geoparsing, Chat, QC schnell: CLAUDE_MODEL_FAST (Haiku) — Feed-Selektion, Topic-Filter, Geoparsing, Uebersetzung, Chat, QC
mittel: CLAUDE_MODEL_MEDIUM (Sonnet) — Entity-Extraktion, Netzwerkanalyse mittel: CLAUDE_MODEL_MEDIUM (Sonnet) — nur Netzwerkanalyse (entity_extractor hat aktuell keinen Aufrufer in der App)
standard: CLI-Default (Opus) — Recherche, Analyse, Faktencheck standard: CLAUDE_MODEL_STANDARD (Opus) — Recherche, Lagebild, Faktencheck
email: aiosmtplib (smtp.ionos.de:587 TLS) email: aiosmtplib (smtp.ionos.de:587 TLS)
frontend: frontend:
typ: Vanilla JS (kein Framework, kein Build-Step) typ: Vanilla JS (kein Framework, kein Build-Step)
design: AegisSight Dark/Light Theme (Navy/Gold) design: AegisSight Dark/Light Theme (Navy/Gold)
fonts: Poppins (Titel), Inter (Body) fonts: Poppins (Titel), Inter (Body)
layout: gridstack.js (Drag-and-Drop Dashboard-Kacheln) layout: Reiter-Ansicht je Lage (layout.js; gridstack wurde abgeloest, nur Legacy-Stubs uebrig)
karte: Leaflet + MarkerCluster karte: Leaflet + MarkerCluster (lokal in vendor/, Kacheln von tile.openstreetmap.de)
echtzeit: WebSocket mit Auto-Reconnect und Ping/Pong echtzeit: WebSocket mit Auto-Reconnect und Ping/Pong
``` ```
@@ -63,25 +63,35 @@ src/:
feedback.py: "E-Mail-Feedback mit Bild-Anhaengen" feedback.py: "E-Mail-Feedback mit Bild-Anhaengen"
tutorial.py: "Tutorial-Fortschritt pro User" tutorial.py: "Tutorial-Fortschritt pro User"
routes/:
version_router.py: "GET /api/version + /api/release-notes (OHNE Auth, fuer das Update-Fenster im Frontend). Historischer Zweitordner neben routers/"
agents/: agents/:
orchestrator.py: "Queue-basierte Refresh-Steuerung, Research Multi-Pass (3 Durchlaeufe), Retry, Cancel, Credits-Tracking" orchestrator.py: "Queue-basierte Refresh-Steuerung (eine Lane je Organisation), Research Multi-Pass (3 Durchlaeufe), Retry, Cancel, Credits-Buchung. _run_refresh ist ~1450 Zeilen, Aenderungen dort nur mit Vorsicht"
researcher.py: "WebSearch-Recherche (Standard + 4-Phasen-Tiefenrecherche), Feed-Selektion, Keyword-Extraktion" stage_runners.py: "Studio-Bausteine: analyze/factcheck einzeln auf vorhandenem Datenbestand, bewusst NEBEN _run_refresh gebaut"
analyzer.py: "Analyse-Agent (Lagebild/Briefing, Erst- + inkrementell, Inline-Zitate)" researcher.py: "WebSearch-Recherche (Standard + 4-Phasen-Tiefenrecherche), Feed-/Kanal-/Account-Selektion, Keyword-Extraktion, Google-News-Suchfeeds je Sprache"
factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Triage), Claim-Matching, Dedup" analyzer.py: "Analyse-Agent (Lagebild/Briefing, Erst- + inkrementell, Inline-Zitate, Topic-Filter, Neueste Entwicklungen, Stimmungsbild)"
geoparsing.py: "Haiku-basierte Ortsextraktion, Geocoding via geonamescache" factchecker.py: "Faktencheck (Erst/Inkrementell/Zwei-Phasen mit Haiku-Triage ab 25 Fakten), Claim-Matching, Dedup, Schutz gegen Massen-Downgrades"
entity_extractor.py: "Netzwerkanalyse: Entity-Extraktion (Sonnet), Beziehungsanalyse, Dedup" geoparsing.py: "Haiku-basierte Ortsextraktion, Geocoding offline via geonamescache"
claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung" entity_extractor.py: "Netzwerkanalyse (Sonnet). ACHTUNG: aktuell ohne Aufrufer in der App, nur regenerate_relations.py im Repo-Root nutzt es"
translator.py: "Haiku-Uebersetzung in Batches a 5 (groessere Batches rissen den JSON-Output ab)"
claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung, Cancel via ContextVar"
feeds/: feeds/:
rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), Keyword-Matching, Domain-Cap" rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), adaptive Keyword-Schwelle, Frische-Bonus, Domain-Cap"
telegram_parser.py: "Telethon-basierter Telegram-Parser, Kanal-Validierung" telegram_parser.py: "Telethon-basierter Telegram-Parser (eine gemeinsame Session), Kanal-Validierung"
x_parser.py: "X/Twitter via twscrape (Account-Store ~/.x-scraper/accounts.db, optional Mobilfunk-Proxy)"
podcast_parser.py: "Podcast-Feeds inkl. Transkript-Extraktoren (transcript_extractors/)"
services/: services/:
post_refresh_qc.py: "Post-Refresh Quality Check: Faktencheck-Duplikate, Location-Korrektur" pipeline_tracker.py: "Die 11 Pipeline-Schritte (DE/EN, Laien-Tooltips), schreibt refresh_pipeline_steps + WebSocket-Events"
fact_consolidation.py: "Periodisches Haiku-Clustering, Auto-Resolve veralteter Fakten" post_refresh_qc.py: "Post-Refresh Quality Check: Faktencheck-Duplikate, Location-Korrektur, Umlaut-Normalisierung"
source_health.py: "Quellen-Health-Checks (Erreichbarkeit, Feed-Validitaet, Stale)" fact_consolidation.py: "Periodisches Haiku-Clustering. ACHTUNG: als 6h-Job dokumentiert, aber NICHT im Scheduler eingeplant"
source_suggester.py: "KI-Quellen-Vorschlaege via Haiku" source_health.py: "Quellen-Health-Checks (Erreichbarkeit, Feed-Validitaet, Stale, fetch_strategy-Logik)"
license_service.py: "Lizenz-Pruefung (Org, Ablauf, Nutzer-Limit)" source_suggester.py: "KI-Quellen-Vorschlaege via Haiku + Karteileichen-Heuristik"
pdf_ingest.py: "Minutenjob: hochgeladene PDFs einlesen (pdfplumber + OCR-Fallback), uebersetzen, als Pool-Artikel ablegen"
org_settings.py: "Key-Value-Einstellungen je Organisation (output_language etc.) mit 60s-Cache"
license_service.py: "Lizenz-Pruefung, Credits-Buchung (charge_usage_to_tenant), Periodenwechsel, Budget-Warnung. expire_licenses() existiert, hat aber KEINEN Scheduler-Job"
middleware/: middleware/:
license_check.py: "Dependencies: require_active_license, require_writable_license" license_check.py: "Dependencies: require_active_license, require_writable_license"
@@ -98,17 +108,25 @@ src/:
report.html: "HTML-Template fuer PDF/DOCX-Export" report.html: "HTML-Template fuer PDF/DOCX-Export"
static/: static/:
index.html: "Login-Seite (Magic-Link)" index.html: "Login-Seite (Magic-Link), JS inline"
dashboard.html: "Hauptdashboard (Sidebar + GridStack + Modals)" dashboard.html: "Hauptdashboard (Sidebar + Reiter-Ansicht + 8 Modals + Chat-Widget)"
studio.html: "Studio (3-Spalten-Werkstatt, online nur fuer info@ sichtbar - Gating rein clientseitig)"
css/: css/:
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)" style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
studio.css: "Studio-spezifische Styles"
js/: js/:
api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout)" api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout, 403 -> Nur-Lese-Modus)"
app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt" app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt (181 KB)"
components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress-Bar, Karte" components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress, Karte (geteilt mit Studio)"
chat.js: "Chat-Assistent Widget" studio.js: "Kompletter Studio-Controller (109 KB)"
layout.js: "gridstack.js Wrapper (Drag/Resize, localStorage)" chat.js: "Chat-Assistent Widget (Bedienfragen)"
tutorial.js: "Interaktiver 32-Schritte Rundgang mit Animationen" pipeline.js: "Grafische Analysepipeline (11 Schritte, Live-Animation)"
layout.js: "Reiter-Umschalter je Lage (merkt letzten Reiter; enthaelt noch gridstack-Legacy-Stubs)"
tutorial.js: "32-Schritte-Rundgang (140 KB). AKTUELL DEAKTIVIERT, alle Einstiege auskommentiert"
a11y.js: "Barrierefreiheits-Panel (identische Datei wie im Verwaltungsportal)"
update-system.js: "Was-ist-neu-Modal aus RELEASES.json + Update-Banner"
ai-disclaimer.js: "KI-Haftungshinweis beim ersten Besuch"
i18n.js: "Mini-Uebersetzung de/en (nur teilweise verdrahtet, Studio/Login komplett deutsch)"
ws.js: "WebSocket-Client (Reconnect, Ping/Pong)" ws.js: "WebSocket-Client (Reconnect, Ping/Pong)"
vendor/: vendor/:
leaflet.js: "Karten-Bibliothek" leaflet.js: "Karten-Bibliothek"
@@ -138,37 +156,51 @@ incident_typen:
cancel: "Zwischen und innerhalb der Durchlaeufe moeglich" cancel: "Zwischen und innerhalb der Durchlaeufe moeglich"
refresh_pipeline: refresh_pipeline:
1: "Feed-Selektion (Haiku) + dynamische Keywords" hinweis: "Die 11 nutzersichtbaren Schritte definiert services/pipeline_tracker.py. Interner Ablauf in _run_refresh:"
2: "Parallel: RSS + WebSearch + optional Telegram" 1: "Feed-/Kanal-/Account-Selektion (Haiku) + dynamische Keywords je Sprache"
3: "URL-Verifizierung (HEAD-Requests)" 2: "Parallel sammeln: RSS + Google-News-Suchfeeds + WebSearch (Opus) + optional Telegram + X + Podcasts (nur adhoc)"
4: "Duplikaterkennung (URL + Headline)" 3: "URL-Verifizierung (HEAD-Requests, tote URLs werden zu site:-Suchlinks repariert)"
5: "Relevanz-Scoring + DB-Dedup" 4: "Duplikaterkennung (URL + Headline, dann gegen DB-Bestand)"
6: "Geoparsing (Haiku + geonamescache)" 5: "Relevanz-Scoring + semantischer Topic-Filter (Haiku)"
7: "Parallel: Analyse + Faktencheck" 6: "Geoparsing (Haiku + geonamescache offline)"
8: "Post-Refresh QC" 7: "Faktencheck ZUERST (liefert Faktenkontext), DANN Lagebild/Briefing (Opus). Optional Stimmungsbild aus Foren-Quellen"
9: "Notifications (DB + E-Mail + WebSocket)" 8: "Uebersetzung fehlender DE-Texte (Haiku, nur wenn TRANSLATOR_ENABLED) + Neueste Entwicklungen (nur adhoc)"
10: "Credits-Tracking (Token auf Lizenz buchen)" 9: "Post-Refresh QC (Fakten-Dubletten, Karten-Kategorien, Umlaute)"
11: "Background: Source-Discovery" 10: "Notifications (DB + E-Mail + WebSocket), Credits-Buchung (flat: adhoc 45 / research 40 je Durchlauf)"
11: "Background: Source-Discovery + Executive Summary"
multi_tenancy: "Volle Mandantentrennung (tenant_id auf allen Tabellen)" multi_tenancy: |
Mandantentrennung ueber tenant_id-Filter je Abfrage (keine Middleware). Bei Lagen zentral
ueber _check_incident_access. ACHTUNG, nicht alle Tabellen haben tenant_id
(source_health_checks, source_suggestions, incident_subscriptions, billing_tariff),
und der WebSocket-Broadcast filtert Stand 07/2026 NICHT nach Mandant.
dashboard_kacheln: lage_reiter:
- "Lagebild (Markdown + Inline-Zitate)" - "Neueste Entwicklungen (research: Zusammenfassung)"
- "Lagebild (research: Recherchebericht, Markdown + Inline-Zitate)"
- "Ereignis-Timeline (horizontale Achse, Bucketing, Filter)"
- "Geografische Verteilung (Leaflet, Kategorie-Marker, Legende)"
- "Faktencheck (Status-Icons, Evidence, Filter)" - "Faktencheck (Status-Icons, Evidence, Filter)"
- "Quellenübersicht (nach Domain gruppiert)" - "Oeffentliche Stimmung (nur bei Foren-Quellen)"
- "Timeline (horizontale Achse, Bucketing, Filter)" - "Analysepipeline (grafisch, 11 Schritte)"
- "Karte (Leaflet, Kategorie-Marker, Legende)" - "Quellenuebersicht (nach Domain gruppiert)"
``` ```
## Datenbank (25+ Tabellen) ## Datenbank (30+ Tabellen)
```yaml ```yaml
kern: "organizations, licenses, users, magic_links, portal_admins" kern: "organizations, licenses, users, magic_links, organization_settings, portal_admins (gehoert dem Portal)"
lagen: "incidents, articles, incident_snapshots, fact_checks, refresh_log" lagen: "incidents, articles, incident_snapshots, fact_checks, incident_events, fact_check_runs"
quellen: "sources, source_health_checks, source_suggestions, user_excluded_domains" pipeline: "refresh_log, refresh_pipeline_steps"
quellen: "sources, source_alignments, source_health_checks, source_suggestions, user_excluded_domains, podcast_transcripts"
geo: "article_locations" geo: "article_locations"
netzwerk: "network_analyses, network_analysis_incidents, network_entities, network_entity_mentions, network_relations, network_generation_log" netzwerk: "network_analyses, network_analysis_incidents, network_entities, network_entity_mentions, network_relations, network_generation_log (alle leer, kein Endpoint im Monitor)"
system: "notifications, incident_subscriptions, feedback, token_usage_monthly" abrechnung: "token_usage_monthly, billing_tariff, user_activity_days"
system: "notifications, incident_subscriptions, system_status"
hinweise:
- "Es gibt KEINE feedback-Tabelle, Feedback geht direkt per E-Mail raus"
- "Portal-Tabellen (portal_audit_log, portal_magic_links, source_health_history u.a.) liegen in derselben Live-DB, werden aber nur vom Verwaltungsportal angelegt/genutzt"
- "users.is_global_admin/globe_access/network_access legt NUR das Portal an. Eine frisch per init_db erzeugte Monitor-DB kann daher keinen Login (no such column)"
``` ```
## Verwandte Projekte (gleicher Server) ## Verwandte Projekte (gleicher Server)
@@ -199,7 +231,7 @@ regeln:
- "Jede Aenderung MUSS sofort committed und nach Gitea gepusht werden" - "Jede Aenderung MUSS sofort committed und nach Gitea gepusht werden"
- "Echte Umlaute in UI-Texten (ue, ae, oe, ss), keine Umschreibungen" - "Echte Umlaute in UI-Texten (ue, ae, oe, ss), keine Umschreibungen"
- "Keine Passwoerter oder Secrets in den Code committen" - "Keine Passwoerter oder Secrets in den Code committen"
- "Service nach Backend-Aenderungen: sudo systemctl restart osint-monitor" - "Service nach Backend-Aenderungen: sudo systemctl restart aegis-monitor (Staging startet der Auto-Deploy selbst neu)"
- "Frontend-Aenderungen (HTML/JS/CSS) brauchen keinen Neustart" - "Frontend-Aenderungen (HTML/JS/CSS) brauchen keinen Neustart"
- "Backup-Dateien (.bak) nicht committen, vor Push loeschen" - "Backup-Dateien (.bak) nicht committen, vor Push loeschen"
``` ```
@@ -222,8 +254,13 @@ ui_sync:
wortlaut: "Die Verbrauchseinheit heißt in BEIDEN Monitoren 'Credits' (Entscheidung Nutzer 2026-07-25). Nicht Guthaben, nicht Einheiten." wortlaut: "Die Verbrauchseinheit heißt in BEIDEN Monitoren 'Credits' (Entscheidung Nutzer 2026-07-25). Nicht Guthaben, nicht Einheiten."
gewollte_unterschiede: gewollte_unterschiede:
- "Online: Studio nur für info@aegis-sight.de freigegeben (Gating in studio.js init, Studio-Link im Dashboard-Header versteckt). Lokal ohne Gating, Knopf immer sichtbar." - "Online: Studio nur für info@aegis-sight.de freigegeben (Gating in studio.js init, Studio-Link im Dashboard-Header versteckt). Lokal ohne Gating, Knopf immer sichtbar."
- "Lokal: X-Zugänge-Oberfläche (twscrape) in Sidebar/Modal/Quellenübersicht. Online bewusst nicht vorhanden." - "Lokal: X-Zugänge-Oberfläche (twscrape) in Sidebar/Modal/Quellenübersicht. Online bewusst nicht vorhanden (kein x-Router im Online-Backend)."
- "Lokal: Takt-Features (Untergrenze 30 Min, Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578). Online noch nicht portiert. Die Credits-Abrechnung selbst ist seit 25.07.2026 auf beiden Seiten (docs/ABRECHNUNG.md)." - "Lokal: Auto-Login über /api/auth/dev-login (Demo-Modus). Online ausschließlich Magic-Link."
- "Lokal: Kostenvorschau im Anlege-Dialog (updateIntervalCostHint, Fork-Commit 5b0b578). Online noch nicht portiert."
bekannter_drift_stand_2026_07_26:
- "Die Takt-Untergrenze ist inzwischen AUCH online (dort _getMinIntervalMinutes, 30 Min; lokal _intervalMinMinutes mit Vorgabe 12 Std). Ältere Angaben, sie fehle online, sind überholt."
- "Online-only, noch nicht in den Fork portiert: Reiter 'Öffentliche Stimmung' (renderPublicMood) und die Fall-Chat-Rückfrage /clarify."
- "Echter Inhalts-Drift in rund 11 Dateien (app.js, components.js, api.js, studio.js, style.css u.a.), weitere ~7 Dateien unterscheiden sich nur durch Umlaute in Kommentaren."
stolperfalle_zeilenenden: | stolperfalle_zeilenenden: |
Die Frontend-Dateien (src/static) sind CRLF, die Python-Dateien dieses Repos sind LF Die Frontend-Dateien (src/static) sind CRLF, die Python-Dateien dieses Repos sind LF
(im Lokal-Fork teils anders). Beim Portieren keine Werkzeuge einsetzen, die Zeilenenden (im Lokal-Fork teils anders). Beim Portieren keine Werkzeuge einsetzen, die Zeilenenden
@@ -262,20 +299,22 @@ staging:
zugriff: Magic-Link-Login an info@aegis-sight.de (Cookie 30 Tage) zugriff: Magic-Link-Login an info@aegis-sight.de (Cookie 30 Tage)
datenbank: datenbank:
pfad: ~/AegisSight-Monitor-staging/data/osint.db pfad: /home/claude-dev/osint-data-staging/osint.db (per DB_PATH in .env gesetzt)
achtung: "~/AegisSight-Monitor-staging/data/osint.db ist eine UNBENUTZTE Altkopie"
initial: einmalige Kopie der Live-DB initial: einmalige Kopie der Live-DB
drift: gewollt - Aenderungen in Staging beeinflussen Live nicht drift: gewollt - Aenderungen in Staging beeinflussen Live nicht
reseed_von_live: | reseed_von_live: |
sudo systemctl stop aegis-monitor-staging sudo systemctl stop aegis-monitor-staging
cp ~/AegisSight-Monitor/data/osint.db ~/AegisSight-Monitor-staging/data/osint.db cp /home/claude-dev/osint-data/osint.db /home/claude-dev/osint-data-staging/osint.db
sudo systemctl start aegis-monitor-staging sudo systemctl start aegis-monitor-staging
besonderheiten_env: besonderheiten_env:
JWT_SECRET: eigener fuer Staging (nicht Live-JWT) JWT_SECRET: eigener fuer Staging (nicht Live-JWT)
MAGIC_LINK_BASE_URL: https://staging.monitor.aegis-sight.de (sonst leitet App zu Live) 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 DB_PATH: /home/claude-dev/osint-data-staging/osint.db (Live setzt KEIN DB_PATH, dort greift der Default data/ = Symlink auf ~/osint-data)
TELEGRAM_API_HASH: 0 STAGING_MODE: 1 (unlimited_budget, kein Credits-Hard-Stop)
DB-Pfad: relative aus config.py (nutzt automatisch ~/AegisSight-Monitor-staging/data/) TRANSLATOR_ENABLED: false
TELEGRAM_SESSION_PATH: ~/.telegram-staging/ (eigene Session, dort ist NIEMAND eingeloggt - Telegram-Recherche auf Staging liefert nichts, die Status-Karte im Portal zeigt das ehrlich an)
auth_service: auth_service:
pfad: /opt/aegis-staging-auth pfad: /opt/aegis-staging-auth
@@ -288,33 +327,29 @@ staging:
### Workflow Staging -> Live ### Workflow Staging -> Live
1. **Aenderung in develop machen** (im Staging-Verzeichnis): Der gueltige Ablauf steht unten unter "Vollstaendiger Workflow": develop pushen,
```bash Auto-Deploy aktualisiert Staging von selbst, auf Staging pruefen, Promote ueber
cd ~/AegisSight-Monitor-staging https://deploy.aegis-sight.de, Live-Check. Manuelles git pull auf dem Server ist
git checkout develop NICHT noetig und sollte unterbleiben (der Deploy-Listener macht git reset --hard,
# Aenderung lokale Aenderungen in den Server-Verzeichnissen gehen dabei verloren).
git add . && git commit -m ... && git push origin develop
```
2. **Staging aktualisieren** (aktuell manuell): Stolperfalle bei der Warteschlangen-Pruefung: das Staging-Verzeichnis fetcht per
```bash Fetch-Regel NUR develop. Fuer "wie viele Commits warten auf Promote" immer erst
ssh claude-dev@46.225.141.13 'cd ~/AegisSight-Monitor-staging && git pull && sudo systemctl restart aegis-monitor-staging' git fetch origin main ausfuehren, sonst zeigt main..develop veraltete Zahlen.
```
3. **In https://staging.monitor.aegis-sight.de testen** ### Bekannte Baustellen (Stand 2026-07-26)
4. **Promote zu Live**: Pull Request develop -> main in Gitea, dann: - WebSocket-Broadcast filtert nicht nach Mandant (main.py broadcast_for_incident bekommt tenant_id, nutzt ihn nicht)
```bash - POST /api/public/globe-ingest schreibt ohne Eigentuemer-Pruefung in beliebige Lagen, Artikel ohne tenant_id
ssh claude-dev@46.225.141.13 'cd ~/AegisSight-Monitor && git pull' - fact_consolidation und expire_licenses existieren, haben aber keinen Scheduler-Job
# Live laeuft als loser uvicorn-Prozess (kein systemd) - manueller Restart - entity_extractor/Netzwerkanalyse ohne Aufrufer in der App (nur regenerate_relations.py)
# bei Backend-Aenderungen noetig - tutorial.js deaktiviert (Einstiege auskommentiert), laedt aber weiter bei jedem Login
``` - dashboard.html laedt nicht existierende cluster-data.js (404) und ungenutztes d3.js vom CDN
- Reiter "Oeffentliche Stimmung" fehlt in layout.js TAB_ORDER und ist dadurch nicht anklickbar
### Offen (noch nicht implementiert) - X-Schalter im Anlege-Dialog hat online keine Funktion (kein X-Backend online)
- i18n unvollstaendig (Studio und Login nur deutsch), Begriffs-Mix Fall/Lage/Vorfall, zweimal "Token-Budget" statt "Credits" in app.js
- Auto-Deploy bei Push auf develop (Webhook-Listener) - Unter 768px verschwindet die Sidebar ersatzlos (mobil unbenutzbar)
- Promote-UI mit Ein-Klick-Button - Budget-Warnung geht nur in die Glocke, nicht per E-Mail (siehe docs/ABRECHNUNG.md)
- Live-Monitor auf systemd umstellen (~10s Downtime einmalig)
## Auto-Deploy + Promote-UI ## Auto-Deploy + Promote-UI

Datei anzeigen

@@ -1,4 +1,21 @@
[ [
{
"version": "2026-07-26T02:18Z",
"date": "2026-07-26",
"title": "Interne Verbesserungen",
"items": []
},
{
"version": "2026-07-25T23:31Z",
"date": "2026-07-25",
"title": "Abrechnung, Budget-Warnung & Telegram-Verbesserungen",
"items": [
"Nicht genutzte Credits verfallen am Monatsende und werden nicht mehr in den Folgemonat übertragen.",
"Die Budget-Warnung lässt sich durch Setzen der Warnschwelle auf 0 gezielt deaktivieren.",
"Telegram-Statusmeldungen zeigen jetzt die zugehörige Telefonnummer an.",
"Der Verbindungsstatus der Telegram-Sitzung ist jetzt im Systemstatus einsehbar."
]
},
{ {
"version": "2026-07-23T21:10Z", "version": "2026-07-23T21:10Z",
"date": "2026-07-23", "date": "2026-07-23",

Datei anzeigen

@@ -15,9 +15,12 @@ einheitlich in Monitor und Verwaltungsportal).
## Was eine Aktion kostet ## Was eine Aktion kostet
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz,
`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich unabhängig davon, was sie uns tatsächlich verursacht hat. 1 Credit entspricht
verursacht hat. 1 Credit entspricht 0,20 USD. 0,20 USD. Die wirksamen Sätze stehen seit 25.07.2026 in der geteilten Tabelle
`billing_tariff` und sind über den Verbrauchsrechner des Verwaltungsportals
pflegbar. `CREDIT_TARIFF` in `src/config.py` befüllt die Tabelle beim ersten
Start und bleibt Rückfallebene für fehlende Schlüssel.
| Aktion | Credits | Herkunft des Werts | | Aktion | Credits | Herkunft des Werts |
|---|---|---| |---|---|---|
@@ -58,11 +61,9 @@ Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen
Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht. nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt Ungenutzte Credits verfallen zum Periodenende. Ein Übertrag in den Folgemonat
auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim war kurz vorgesehen und wurde als Produktentscheidung 07/2026 wieder entfernt,
Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist. es gilt der harte Monatsdeckel wie verkauft.
Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im
Quartal, und genau dann darf das System nicht dichtmachen.
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
@@ -90,7 +91,6 @@ nächste Schritt.
| `credits_total` | Kontingent je Periode | 10000 | | `credits_total` | Kontingent je Periode | 10000 |
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 | | `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
| `credits_period` | `monthly` oder `total` | monthly | | `credits_period` | `monthly` oder `total` | monthly |
| `credits_rollover` | Übertrag erlauben | 1 |
| `budget_warning_percent` | Warnschwelle in Prozent | 80 | | `budget_warning_percent` | Warnschwelle in Prozent | 80 |
| `unlimited_budget` | Kontingent aushebeln | 0 | | `unlimited_budget` | Kontingent aushebeln | 0 |
@@ -138,8 +138,6 @@ Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf.
## Offene Punkte ## Offene Punkte
- E-Mail-Versand der Budget-Warnung. - E-Mail-Versand der Budget-Warnung.
- Eingabefelder für die Stellwerte im Verwaltungsportal (heute erzeugt das
Anlegen einer Organisation dort nicht einmal eine Lizenz).
- Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben). - Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben).
- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine - Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine
Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur

Datei anzeigen

@@ -59,6 +59,41 @@ def decode_token(token: str) -> dict:
) )
# --- Aktivitaets-Erfassung fuer MAU/DAU ---------------------------------------
# Je Nutzer und Kalendertag genau ein Eintrag in user_activity_days. Der
# In-Memory-Merker haelt die DB-Last bei einem INSERT je Nutzer und Tag.
# Ein Fehler hier darf NIEMALS eine Anfrage blockieren.
_activity_seen: set = set()
async def _track_activity(user_id: int, tenant_id) -> None:
day = datetime.now(TIMEZONE).strftime("%Y-%m-%d")
key = (user_id, day)
if key in _activity_seen:
return
_activity_seen.add(key)
if len(_activity_seen) > 20000:
# Speicher begrenzen, alte Tage interessieren den Merker nicht mehr
_activity_seen.clear()
_activity_seen.add(key)
try:
import aiosqlite
from config import DB_PATH
db = await aiosqlite.connect(DB_PATH)
try:
await db.execute(
"INSERT OR IGNORE INTO user_activity_days (user_id, day, tenant_id) VALUES (?, ?, ?)",
(user_id, day, tenant_id),
)
await db.commit()
finally:
await db.close()
except Exception:
# Bewusst schlucken (z.B. Tabelle fehlt noch). Der Merker behaelt den
# Schluessel, damit ein Dauerfehler nicht jede Anfrage erneut trifft.
pass
async def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict: ) -> dict:
@@ -69,6 +104,7 @@ async def get_current_user(
detail="Nicht authentifiziert", detail="Nicht authentifiziert",
) )
payload = decode_token(credentials.credentials) payload = decode_token(credentials.credentials)
await _track_activity(int(payload["sub"]), payload.get("tenant_id"))
return { return {
"id": int(payload["sub"]), "id": int(payload["sub"]),
"username": payload["username"], "username": payload["username"],

Datei anzeigen

@@ -3,7 +3,7 @@ import aiosqlite
import json import json
import logging import logging
import os import os
from config import DB_PATH, DATA_DIR from config import DB_PATH, DATA_DIR, CREDIT_TARIFF
logger = logging.getLogger("osint.database") logger = logging.getLogger("osint.database")
@@ -877,10 +877,9 @@ async def init_db():
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten # Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen. # Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT") await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
# Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter # Ungenutzte Credits verfallen zum Periodenende. Ein Uebertrag in den
# Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab. # Folgemonat war kurz vorgesehen und wurde als Produktentscheidung
await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0") # 07/2026 wieder entfernt (harter Monatsdeckel wie verkauft).
await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0")
# Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet. # Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet.
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0") await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0")
await db.commit() await db.commit()
@@ -896,6 +895,64 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt") logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
# Migration: Preistabelle fuer die Credits-Saetze. Pflegbare Quelle der
# Wahrheit fuer Monitor UND Verwaltungsportal (das Portal schreibt sie
# ueber den Verbrauchsrechner). Beim ersten Start mit den wirksamen
# Konfigurationswerten befuellt, danach gewinnt die Tabelle,
# CREDIT_TARIFF bleibt Rueckfallebene fuer fehlende Schluessel.
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='billing_tariff'"
)
if not await cursor.fetchone():
await db.execute("""
CREATE TABLE billing_tariff (
tariff_key TEXT PRIMARY KEY,
credits REAL NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for tariff_key, credits in CREDIT_TARIFF.items():
await db.execute(
"INSERT INTO billing_tariff (tariff_key, credits) VALUES (?, ?)",
(tariff_key, float(credits)),
)
await db.commit()
logger.info("Migration: billing_tariff angelegt und mit Konfigurationswerten befuellt")
# Migration: Aktivitaets-Tage je Nutzer fuer die MAU/DAU-Statistik im
# Verwaltungsportal. Geschrieben von auth.get_current_user (ein Eintrag
# je Nutzer und Tag), gelesen nur vom Portal.
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_activity_days'"
)
if not await cursor.fetchone():
await db.execute("""
CREATE TABLE user_activity_days (
user_id INTEGER NOT NULL,
day TEXT NOT NULL,
tenant_id INTEGER,
PRIMARY KEY (user_id, day)
)
""")
await db.commit()
logger.info("Migration: user_activity_days angelegt (MAU/DAU-Erfassung)")
# Migration: System-Status (Key-Value). Der Monitor meldet hier z.B. den
# Telegram-Session-Status, das Verwaltungsportal liest ihn nur an.
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='system_status'"
)
if not await cursor.fetchone():
await db.execute("""
CREATE TABLE system_status (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
logger.info("Migration: system_status angelegt (u.a. Telegram-Session-Status)")
# Migration: Token-Usage-Monatstabelle # Migration: Token-Usage-Monatstabelle
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'") cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
if not await cursor.fetchone(): if not await cursor.fetchone():

Datei anzeigen

@@ -222,6 +222,42 @@ async def daily_source_health_check():
finally: finally:
await db.close() await db.close()
async def update_telegram_status():
"""Prüft die Telegram-Session und meldet den Status in system_status.
Das Verwaltungsportal zeigt den Eintrag im Reiter Recherche-Zugänge an.
Nutzt den eigenen Telethon-Client des Prozesses, ein Fehler darf den
Betrieb nie stören (nur Status + Log).
"""
db = await get_db()
try:
status = {"ok": False, "account": None, "error": None}
try:
from feeds.telegram_parser import TelegramParser
client = await TelegramParser()._get_client()
if client:
me = await client.get_me()
status["ok"] = True
status["account"] = ((me.first_name or "").strip() or None) if me else None
status["phone"] = ("+" + me.phone) if me and me.phone else None
else:
status["error"] = "Session fehlt oder nicht autorisiert"
except Exception as e:
status["error"] = str(e)
await db.execute(
"INSERT INTO system_status (key, value, updated_at) "
"VALUES ('telegram_session', ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
(json.dumps(status),),
)
await db.commit()
logger.info(f"Telegram-Status gemeldet: ok={status['ok']}")
except Exception as e:
logger.error(f"Telegram-Status-Update fehlgeschlagen: {e}", exc_info=True)
finally:
await db.close()
async def cleanup_expired(): async def cleanup_expired():
"""Bereinigt abgelaufene Lagen basierend auf retention_days.""" """Bereinigt abgelaufene Lagen basierend auf retention_days."""
db = await get_db() db = await get_db()
@@ -344,8 +380,12 @@ async def lifespan(app: FastAPI):
scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh") scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh")
scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup") scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup")
scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health") scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health")
scheduler.add_job(update_telegram_status, "cron", hour=4, minute=30, id="telegram_status")
scheduler.start() scheduler.start()
# Telegram-Status einmal beim Start melden (asynchron, blockiert den Start nicht)
asyncio.create_task(update_telegram_status())
logger.info("OSINT Lagemonitor gestartet") logger.info("OSINT Lagemonitor gestartet")
yield yield

Datei anzeigen

@@ -41,8 +41,8 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird. ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode, Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst. Produktentscheidung 07/2026).
Returns: Returns:
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt). Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
@@ -70,33 +70,21 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
if started == period: if started == period:
return lic return lic
total = lic.get("credits_total") or 0
carried_old = lic.get("credits_carried") or 0
used = lic.get("credits_used") or 0
if lic.get("credits_rollover"):
carried_new = max(0.0, (total + carried_old) - used)
carried_new = min(carried_new, float(total)) # hoechstens ein Monat
else:
carried_new = 0.0
await db.execute( await db.execute(
"""UPDATE licenses """UPDATE licenses
SET credits_used = 0, credits_carried = ?, credits_period_start = ?, SET credits_used = 0, credits_period_start = ?,
budget_warning_sent = 0 budget_warning_sent = 0
WHERE id = ?""", WHERE id = ?""",
(round(carried_new, 2), period, lic["id"]), (period, lic["id"]),
) )
await db.commit() await db.commit()
lic["credits_used"] = 0 lic["credits_used"] = 0
lic["credits_carried"] = carried_new
lic["credits_period_start"] = period lic["credits_period_start"] = period
lic["budget_warning_sent"] = 0 lic["budget_warning_sent"] = 0
logger.info( logger.info(
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt " f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt"
f"(Uebertrag {round(carried_new, 2)} Einheiten)"
) )
return lic return lic
@@ -185,10 +173,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
unlimited_budget = bool(lic_dict.get("unlimited_budget")) unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total") credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0 credits_used = lic_dict.get("credits_used") or 0
credits_carried = lic_dict.get("credits_carried") or 0
credits_period = lic_dict.get("credits_period") or "monthly" credits_period = lic_dict.get("credits_period") or "monthly"
# Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat. # Verfuegbar ist genau das Kontingent. Ungenutzte Credits verfallen zum
credits_available = (credits_total or 0) + credits_carried # Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
credits_available = credits_total or 0
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited # STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode(): if _staging_mode():
@@ -219,7 +207,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
pass pass
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited). # Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
budget_exceeded = False budget_exceeded = False
if not unlimited_budget and credits_available > 0: if not unlimited_budget and credits_available > 0:
if credits_used >= credits_available: if credits_used >= credits_available:
@@ -245,7 +232,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": False, "unlimited_budget": False,
"credits_total": credits_total, "credits_total": credits_total,
"credits_used": credits_used, "credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available, "credits_available": credits_available,
"credits_period": credits_period, "credits_period": credits_period,
} }
@@ -262,7 +248,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": unlimited_budget, "unlimited_budget": unlimited_budget,
"credits_total": credits_total, "credits_total": credits_total,
"credits_used": credits_used, "credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available, "credits_available": credits_available,
"credits_period": credits_period, "credits_period": credits_period,
} }
@@ -371,6 +356,24 @@ async def _record_usage_statistics(
) )
async def _load_tariff(db: aiosqlite.Connection) -> dict:
"""Wirksame Credits-Saetze je Aktion.
Die Tabelle billing_tariff ist die pflegbare Quelle (das Verwaltungsportal
schreibt sie), CREDIT_TARIFF aus der Konfiguration bleibt Rueckfallebene
fuer fehlende Schluessel oder eine noch fehlende Tabelle.
"""
tariff = dict(CREDIT_TARIFF)
try:
cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff")
for row in await cursor.fetchall():
if row["credits"] is not None and row["credits"] > 0:
tariff[row["tariff_key"]] = float(row["credits"])
except Exception:
pass # Tabelle existiert noch nicht, die Konfiguration gilt
return tariff
async def _charge_credits( async def _charge_credits(
db: aiosqlite.Connection, db: aiosqlite.Connection,
tenant_id: int, tenant_id: int,
@@ -398,7 +401,8 @@ async def _charge_credits(
key = _tariff_key(source, incident_type) key = _tariff_key(source, incident_type)
if BILLING_MODE == "flat": if BILLING_MODE == "flat":
credits_consumed = CREDIT_TARIFF.get(key) tariff = await _load_tariff(db)
credits_consumed = tariff.get(key)
if credits_consumed is None: if credits_consumed is None:
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als # Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
# stillschweigend gratis abzugeben. # stillschweigend gratis abzugeben.
@@ -418,7 +422,7 @@ async def _charge_credits(
) )
used_new = (lic.get("credits_used") or 0) + credits_consumed used_new = (lic.get("credits_used") or 0) + credits_consumed
available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0) available = lic.get("credits_total") or 0
logger.info( logger.info(
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: " f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
@@ -449,7 +453,12 @@ async def _check_budget_warning(
if available <= 0 or lic.get("budget_warning_sent"): if available <= 0 or lic.get("budget_warning_sent"):
return return
threshold = lic.get("budget_warning_percent") or 80 # 0 schaltet die Warnung bewusst aus, NULL faellt auf die Voreinstellung 80.
threshold = lic.get("budget_warning_percent")
if threshold is None:
threshold = 80
if threshold <= 0:
return
percent = (used / available) * 100 percent = (used / available) * 100
if percent < threshold: if percent < threshold:
return return