From 1bbbd891ef4bcc3674c03e3921a0a7eefe713c08 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 12:26:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(abrechnung):=20Monatskontingent=20in=20Cre?= =?UTF-8?q?dits=20mit=20festen=20S=C3=A4tzen,=20Hard-Stop=20und=20Warnschw?= =?UTF-8?q?elle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit d2b9168, per Cherry-Pick übernommen und an den Server-Stand angepasst). - Feste Sätze je Aktion (BILLING_MODE=flat) statt echter Kosten geteilt durch cost_per_credit. Sätze auf die am 23.07.2026 beschlossenen Verkaufswerte gesetzt. Live-Lauf 45, Recherche je Durchlauf 40, Studio-Bausteine 12, Chat/Beschreibung/Globe 1. Per ENV überschreibbar, der alte Modus bleibt als BILLING_MODE=actual erhalten. - Abrechnungsperiode. licenses.credits_period trennt monthly von total, träger Monatsreset bei der nächsten Lizenzprüfung, optionaler Übertrag (credits_rollover, gedeckelt auf ein Monatskontingent). Bestandslizenzen ohne Periodenmarke behalten ihren Verbrauch. - Hard-Stop gegen das verfügbare Monatskontingent (Kontingent plus Übertrag) statt gegen das Lebenszeit-Total. - Warnschwelle budget_warning_percent (Default 80 Prozent) wird erstmals ausgewertet, einmalige Meldung an alle aktiven Nutzer der Organisation. - DB-Migration additiv und idempotent (credits_period, credits_period_start, credits_rollover, credits_carried, budget_warning_sent, unlimited_budget). - /api/auth/me liefert credits_available als Bezugsgröße plus credits_period, das Credits-Widget zeigt "Credits diesen Monat". - Wortlaut überall Credits (nicht Guthaben/Einheiten), docs/ABRECHNUNG.md auf den Online-Stand gebracht. Abweichungen zur Fork-Vorlage. Die Takt-Änderungen (Untergrenze 30 Min, Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578) sind bewusst NICHT enthalten, die Sätze stehen auf 45/40 statt der Fork-Defaults 24/33. Getestet gegen eine Kopie der Staging-DB, 20 Prüfungen bestanden. Migration, Flat-Buchung adhoc/research/chat, Warnschwelle einmalig, Hard-Stop, Monatsreset, Übertrag gedeckelt, Bestandslizenz ohne Marke. Die vier Live-Lizenzen stehen auf unlimited_budget und sind unbeeinflusst. Co-Authored-By: Claude Fable 5 --- docs/ABRECHNUNG.md | 149 +++++++++++++++++ src/agents/orchestrator.py | 11 +- src/config.py | 45 ++++++ src/database.py | 33 ++++ src/models.py | 3 + src/routers/auth.py | 24 +-- src/services/license_service.py | 277 +++++++++++++++++++++++++++++--- src/static/dashboard.html | 8 +- src/static/i18n/de.json | 7 +- src/static/i18n/en.json | 7 +- src/static/js/api.js | 8 +- src/static/js/app.js | 10 ++ src/static/studio.html | 2 +- 13 files changed, 534 insertions(+), 50 deletions(-) create mode 100644 docs/ABRECHNUNG.md diff --git a/docs/ABRECHNUNG.md b/docs/ABRECHNUNG.md new file mode 100644 index 0000000..5658de5 --- /dev/null +++ b/docs/ABRECHNUNG.md @@ -0,0 +1,149 @@ +# Credits und Abrechnung + +Wie das Kontingent eines Kunden funktioniert, welche Stellschrauben es gibt und +was die Verwaltung setzen muss. Stand 2026-07-25 (Portierung aus dem Lokal-Fork, +Sätze auf die am 23.07.2026 beschlossenen Verkaufswerte gesetzt). + +## Begriffe + +Der Kunde sieht **Credits**, nicht Token und nicht Dollar. Das ist Absicht. +"Token" ist ein KI-Fachbegriff mit einer festen Bedeutung, und 10.000 echte +Token wären etwa 7.500 Wörter, also ein einziger längerer Artikel. Wer den +Begriff kennt, hält so eine Angabe für einen Fehler. Intern heißen die Felder +`credits_*`, nach außen steht überall "Credits" (Entscheidung vom 25.07.2026, +einheitlich in Monitor und Verwaltungsportal). + +## Was eine Aktion kostet + +Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus +`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich +verursacht hat. 1 Credit entspricht 0,20 USD. + +| Aktion | Credits | Herkunft des Werts | +|---|---|---| +| Live-Refresh (adhoc) | 45 | beschlossen 23.07.2026 (gemessener Median 24 Credits = 4,76 USD) | +| Recherche je Durchlauf | 40 | beschlossen 23.07.2026 (gemessener Median 33 Credits = 6,54 USD) | +| Analyse-Baustein (Studio) | 12 | geschätzt, rund ein Viertel eines Live-Laufs | +| Faktencheck-Baustein (Studio) | 12 | geschätzt | +| Chat, Beschreibungs-Assistent, Globe | 1 | real 0,02 bis 0,03 USD je Aufruf | + +Die erste Aktualisierung einer Recherche-Lage fährt drei Durchläufe, kostet also +120 Credits. Die Verkaufssätze liegen bewusst über den gemessenen Medianen. +Messgrundlage sind 1.341 abgeschlossene Refreshes des Live-Systems aus dem +Zeitraum 28.02.2026 bis 21.06.2026 (`refresh_log`). Der Verbrauchsrechner im +Verwaltungsportal rechnet mit denselben Sätzen. + +### Warum feste Sätze und nicht die echten Kosten + +Vorher wurde `echte_kosten / cost_per_credit` abgebucht. Das hatte zwei Nachteile. +Der Kunde konnte nicht planen, weil ein Refresh einer großen Lage ein Vielfaches +eines Refreshs einer frischen kostet, ohne dass er den Unterschied sieht. Und +sobald das Modell-Backend billiger wird, etwa beim Wechsel von Anthropic auf eine +EU-Cloud, hätte derselbe Kunde plötzlich ein Vielfaches an Aktionen bekommen, +oder wir hätten `cost_per_credit` nachziehen müssen, was nach einer heimlichen +Preiserhöhung aussieht. + +Der alte Modus lebt weiter unter `BILLING_MODE=actual` als Rückfallebene. Die +echten Kosten wandern in beiden Modi unverändert nach `token_usage_monthly`, die +interne Kostenkontrolle bleibt also vollständig erhalten. + +## Abrechnungsperiode + +`licenses.credits_period` steuert den Bezugszeitraum. + +- `monthly` (Voreinstellung) füllt die Credits zum Monatswechsel neu auf. +- `total` lässt das Kontingent für die gesamte Lizenzlaufzeit gelten. + +Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen +Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff +nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht. + +`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt +auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim +Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist. +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 +Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr +Credits geschenkt, die im laufenden Monat bereits verbraucht wurden. + +## Warnung und Sperre + +Bei Erreichen von `budget_warning_percent` (Voreinstellung 80) bekommen alle +aktiven Nutzer der Organisation eine Meldung im Benachrichtigungsbereich. Das +Flag `budget_warning_sent` verhindert, dass sich die Warnung bei jeder weiteren +Buchung wiederholt, und wird beim Periodenwechsel zurückgesetzt. + +Sind die Credits aufgebraucht, wechselt die Organisation in den Nur-Lese-Modus. +Bestehende Lagen bleiben vollständig lesbar, es lassen sich nur keine neuen +Aktualisierungen mehr starten. Abgeschaltet wird nichts. + +**Offen.** Die Warnung geht bisher nur in die Oberfläche, nicht per E-Mail. Wer +sich nicht anmeldet, sieht sie nicht. Ein Versand über `email_utils` wäre der +nächste Schritt. + +## Was die Verwaltung je Lizenz setzen muss + +| Spalte | Bedeutung | Beispiel | +|---|---|---| +| `credits_total` | Kontingent je Periode | 10000 | +| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 | +| `credits_period` | `monthly` oder `total` | monthly | +| `credits_rollover` | Übertrag erlauben | 1 | +| `budget_warning_percent` | Warnschwelle in Prozent | 80 | +| `unlimited_budget` | Kontingent aushebeln | 0 | + +Ohne `credits_total` läuft die Organisation ohne Kontingent, dann wird nichts +belastet. So stehen aktuell alle vier Live-Lizenzen (`unlimited_budget=1`), die +Umstellung ändert für sie nichts, bis ihnen ein Kontingent gesetzt wird. +Das Verwaltungsportal hat für diese Stellwerte noch keine Eingabefelder, das ist +ein eigenes, offenes Arbeitspaket auf der Portal-Seite. + +## Einordnung der Größenordnung + +Bei einem Satz von 45 Credits je Live-Lauf entspricht ein Monatskontingent von +10.000 Credits rund 222 Live-Refreshes oder rund 83 neu angelegten Recherchen. +Zum Vergleich, das gesamte Live-System mit 46 aktiven Lagen verbrauchte im Mai +2026 nach den gemessenen Medianen rund 11.700 Credits, mit den Verkaufssätzen +bewertet wären es grob 20.000 bis 22.000. + +Ein Hinweis zur Fortschreibung. Die Ist-Kosten je Refresh sind zwischen März und +Mai 2026 von 3,54 auf 7,80 Dollar gestiegen, vermutlich weil ein Lagebild mit +wachsendem Materialbestand auf mehr Kontext aufsetzt. Falls sich das bestätigt, +verbraucht derselbe Kunde im zweiten Jahr mehr als im ersten. Die Sätze in +`CREDIT_TARIFF` sollten deshalb regelmäßig gegen `token_usage_monthly` geprüft +werden. + +## Auto-Refresh-Takt + +Der Takt ist die wichtigste Stellschraube am Verbrauch. Achtung, im Online-Monitor +liegt die Untergrenze derzeit noch bei 10 Minuten und die Voreinstellung bei +15 Minuten. Ein Live-Refresh dauert gemessen im Median aber 12,7 Minuten, jeder +zehnte länger als 22,9 Minuten, und im 15-Minuten-Takt verbraucht eine einzige +Lage bei 45 Credits je Lauf rund 130.000 Credits im Monat. Der Lokal-Fork hat +dafür bereits eine Lösung (Untergrenze 30 Minuten, Voreinstellung 12 Stunden, +Kostenvorschau im Anlege-Dialog, Commit 5b0b578), deren Portierung noch offen ist. + +Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf. + +| Takt | Refreshes je Monat | Credits | Anteil | +|---|---|---|---| +| 30 Min | 1.440 | 64.800 | 648 Prozent | +| 1 Stunde | 720 | 32.400 | 324 Prozent | +| 6 Stunden | 120 | 5.400 | 54 Prozent | +| 12 Stunden | 60 | 2.700 | 27 Prozent | +| 24 Stunden | 30 | 1.350 | 14 Prozent | + +## Offene Punkte + +- 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). +- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine + Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur + der Hinweistext bei aufgebrauchten Credits darauf zeigen. +- Sätze für die Studio-Bausteine sind geschätzt und sollten nachgemessen werden, + sobald `token_usage_monthly` Zeilen mit `source='analysis'` und `'factcheck'` + enthält. diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index f5fe8cb..d601f34 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -2170,10 +2170,15 @@ class AgentOrchestrator: f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)" ) - # Credits-Tracking: Monatliche Aggregation + Credits abziehen - if tenant_id and usage_acc.total_cost_usd > 0: + # Guthaben belasten + monatliche Statistik fortschreiben. Der Lagentyp + # entscheidet ueber den Tarif, ein Research-Durchlauf kostet mehr als + # ein Live-Refresh. Nicht mehr an total_cost_usd > 0 gebunden, weil im + # Pauschalmodus die Aktion zaehlt und lokale Modelle 0 kosten. + if tenant_id: from services.license_service import charge_usage_to_tenant - await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor") + await charge_usage_to_tenant( + db, tenant_id, usage_acc, source="monitor", incident_type=incident_type + ) await db.commit() # Quellen-Discovery im Background starten diff --git a/src/config.py b/src/config.py index 538537c..0126d46 100644 --- a/src/config.py +++ b/src/config.py @@ -34,6 +34,51 @@ CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed- CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse) CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck +# --- Verbrauchssaetze (Credits je Aktion) ------------------------------------- +# Beschlossen 2026-07-23, der Verbrauchsrechner im Verwaltungsportal nutzt +# dieselben Werte: Live-Lauf 45, Recherche je Durchlauf 40 (das Anlegen faehrt +# drei Durchlaeufe, also 120), 1 Credit = 0,20 USD. Per ENV ueberschreibbar. +# (Die gemessenen Mediane aus refresh_log lagen bei 24 bzw. 33 Credits, die +# Verkaufssaetze sind bewusst hoeher angesetzt.) +CREDITS_PER_ADHOC_REFRESH = float(os.environ.get("CREDITS_PER_ADHOC_REFRESH", "45")) +CREDITS_PER_RESEARCH_PASS = float(os.environ.get("CREDITS_PER_RESEARCH_PASS", "40")) + +# --- Abrechnungsmodus --------------------------------------------------------- +# 'flat' = feste Credits je Aktion nach CREDIT_TARIFF. Der Kunde kann seinen +# Verbrauch vorher ausrechnen, und eine spaetere Verbilligung des +# Modell-Backends veraendert sein Kontingent nicht. +# 'actual' = bisheriges Verhalten, echte Kosten geteilt durch cost_per_credit. +# Damit haengt das Kundenerlebnis am Einkaufspreis, ein Refresh einer +# grossen Lage zieht ein Vielfaches eines Refreshs einer frischen. +# Die echten Kosten wandern in beiden Modi unveraendert nach token_usage_monthly, +# die interne Kostenkontrolle bleibt also erhalten. +BILLING_MODE = os.environ.get("BILLING_MODE", "flat").lower() + +# Credits je Aktion im Modus 'flat'. Schluessel ist die Abrechnungsquelle, +# beim Refresh zusaetzlich nach Lagentyp getrennt. +# +# monitor_* sind die beschlossenen Verkaufssaetze (siehe oben). +# analysis/factcheck sind Einzelbausteine aus dem Studio und liegen mangels +# eigener Messreihe bei rund einem Viertel eines Live-Laufs. Sobald +# token_usage_monthly dafuer Zahlen hat, hier nachziehen. +# chat/enhance/globe kosten real 0,02 bis 0,03 USD je Aufruf, also unter einem +# halben Credit. Der Satz 1 verhindert Missbrauch, ohne echte Nutzung spuerbar +# zu belasten. +CREDIT_TARIFF = { + "monitor_adhoc": CREDITS_PER_ADHOC_REFRESH, + "monitor_research": CREDITS_PER_RESEARCH_PASS, + "analysis": float(os.environ.get("CREDITS_PER_ANALYSIS", "12")), + "factcheck": float(os.environ.get("CREDITS_PER_FACTCHECK", "12")), + "chat": 1.0, + "enhance": 1.0, + "globe": 1.0, +} + +# Voreinstellung fuer neue Lizenzen, wenn die Verwaltung nichts anderes setzt. +# 'monthly' = Kontingent gilt je Kalendermonat und wird zum Monatswechsel neu +# gefuellt. 'total' = Kontingent gilt fuer die gesamte Lizenzlaufzeit. +CREDITS_PERIOD_DEFAULT = os.environ.get("CREDITS_PERIOD_DEFAULT", "monthly") + # Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py # (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en'). # Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne diff --git a/src/database.py b/src/database.py index 3ac64e9..16878d9 100644 --- a/src/database.py +++ b/src/database.py @@ -863,6 +863,39 @@ async def init_db(): await db.commit() logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt") + # Migration: Credits-Periode. Bis hierher war credits_total ein + # Gesamtwert ueber die ganze Lizenzlaufzeit, credits_used wurde nur + # hochgezaehlt und nie zurueckgesetzt. Ein als "monatlich" verkauftes + # Kontingent haette den Kunden nach dem ersten starken Monat dauerhaft + # in den Nur-Lese-Modus gestellt. + cursor = await db.execute("PRAGMA table_info(licenses)") + lic_columns = [row[1] for row in await cursor.fetchall()] + if "credits_period" not in lic_columns: + await db.execute( + "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'" + ) + # Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten + # Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen. + await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT") + # Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter + # Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab. + await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0") + await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0") + # 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.commit() + logger.info("Migration: Credits-Periode zu Lizenzen hinzugefuegt") + + # Migration: unlimited_budget nachziehen. Auf dem Live-Stand existiert die + # Spalte, im Schema fehlte sie -- check_license() las sie defensiv per + # .get() aus und bekam auf frischen Datenbanken immer None. + if "unlimited_budget" not in lic_columns: + await db.execute( + "ALTER TABLE licenses ADD COLUMN unlimited_budget INTEGER DEFAULT 0" + ) + await db.commit() + logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt") + # Migration: Token-Usage-Monatstabelle cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'") if not await cursor.fetchone(): diff --git a/src/models.py b/src/models.py index 4e0e610..769e78b 100644 --- a/src/models.py +++ b/src/models.py @@ -42,6 +42,9 @@ class UserMeResponse(BaseModel): credits_total: Optional[int] = None credits_remaining: Optional[int] = None credits_percent_used: Optional[float] = None + # 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt, 'total' = gilt + # fuer die ganze Lizenzlaufzeit. Steuert nur die Beschriftung im Frontend. + credits_period: Optional[str] = None is_global_admin: bool = False output_language: str = "de" diff --git a/src/routers/auth.py b/src/routers/auth.py index a4b1f96..9f518ff 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -195,21 +195,22 @@ async def get_me( from services.license_service import check_license license_info = await check_license(db, current_user["tenant_id"]) - # Credits-Daten laden (echte Prozente, nicht gekappt) + # Guthaben-Daten aus der Lizenzpruefung uebernehmen. check_license() hat den + # Periodenwechsel bereits nachgeholt, ein zweiter Griff in die Tabelle wuerde + # nur dieselben Werte noch einmal lesen. credits_total = None credits_remaining = None credits_percent_used = None + credits_period = None unlimited_budget = bool(license_info.get("unlimited_budget", False)) - if current_user.get("tenant_id"): - lic_cursor = await db.execute( - "SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", - (current_user["tenant_id"],)) - lic_row = await lic_cursor.fetchone() - if lic_row and lic_row["credits_total"]: - credits_total = lic_row["credits_total"] - credits_used = lic_row["credits_used"] or 0 - credits_remaining = max(0, int(credits_total - credits_used)) - credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0 + if current_user.get("tenant_id") and license_info.get("credits_available"): + # Verfuegbar ist Kontingent plus Uebertrag, danach richtet sich auch der + # Hard-Stop. Die Anzeige muss dieselbe Bezugsgroesse nutzen. + credits_total = int(license_info["credits_available"]) + credits_used = license_info.get("credits_used") or 0 + credits_period = license_info.get("credits_period") + credits_remaining = max(0, int(credits_total - credits_used)) + credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0 # Org-Switcher fuer Global-Admins -- auch auf Staging aktiv, damit eng_demo # und andere Sprach-/Demo-Mandanten via Dropdown erreichbar sind. (Vorherige @@ -240,6 +241,7 @@ async def get_me( unlimited_budget=unlimited_budget, is_global_admin=is_global_admin_response, output_language=output_language_iso, + credits_period=credits_period, ) diff --git a/src/services/license_service.py b/src/services/license_service.py index 5e85f76..208df9c 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -2,7 +2,7 @@ import logging import os from datetime import datetime -from config import TIMEZONE +from config import TIMEZONE, BILLING_MODE, CREDIT_TARIFF import aiosqlite logger = logging.getLogger("osint.license") @@ -17,6 +17,126 @@ def _staging_mode() -> bool: return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes") +def _current_period() -> str: + """Kennung der laufenden Abrechnungsperiode (Kalendermonat).""" + return datetime.now(TIMEZONE).strftime("%Y-%m") + + +def _tariff_key(source: str, incident_type: str | None) -> str: + """Abrechnungsquelle auf einen Tarifschluessel abbilden. + + Ein Refresh kostet je nach Lagentyp unterschiedlich viel, deshalb wird + 'monitor' anhand des Typs aufgeteilt. Alle anderen Quellen entsprechen + direkt einem Schluessel in CREDIT_TARIFF. + """ + if source == "monitor": + return "monitor_research" if incident_type == "research" else "monitor_adhoc" + return source + + +async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict: + """Setzt das Guthaben zurueck, wenn eine neue Abrechnungsperiode begonnen hat. + + Wird traege bei jeder Lizenzpruefung aufgerufen statt ueber einen Zeitplan. + Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff + ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird. + + Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode, + gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst. + + Returns: + Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt). + """ + if (lic.get("credits_period") or "monthly") != "monthly": + return lic + if not lic.get("credits_total"): + return lic + + period = _current_period() + started = lic.get("credits_period_start") + + if not started: + # Bestandslizenz ohne Periodenmarke. Marke setzen, Verbrauch stehen + # lassen -- ein Reset wuerde dem Kunden hier Guthaben schenken, das er + # in diesem Monat schon verbraucht hat. + await db.execute( + "UPDATE licenses SET credits_period_start = ? WHERE id = ?", + (period, lic["id"]), + ) + await db.commit() + lic["credits_period_start"] = period + return lic + + if started == period: + 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( + """UPDATE licenses + SET credits_used = 0, credits_carried = ?, credits_period_start = ?, + budget_warning_sent = 0 + WHERE id = ?""", + (round(carried_new, 2), period, lic["id"]), + ) + await db.commit() + + lic["credits_used"] = 0 + lic["credits_carried"] = carried_new + lic["credits_period_start"] = period + lic["budget_warning_sent"] = 0 + + logger.info( + f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt " + f"(Uebertrag {round(carried_new, 2)} Einheiten)" + ) + return lic + + +async def _notify_budget_warning( + db: aiosqlite.Connection, organization_id: int, percent: float, remaining: float +) -> None: + """Legt eine Warnung an, sobald die Schwelle des Guthabens erreicht ist. + + Die Meldung geht an alle aktiven Nutzer der Organisation, die sich schon + einmal angemeldet haben. Ein E-Mail-Versand haengt hier bewusst nicht dran, + das waere ein eigener Schritt ueber email_utils. + """ + cursor = await db.execute( + "SELECT id FROM users WHERE organization_id = ? AND is_active = 1 AND last_login_at IS NOT NULL", + (organization_id,), + ) + user_ids = [row["id"] for row in await cursor.fetchall()] + now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S") + + title = "Credits zu {p} Prozent verbraucht".format(p=int(percent)) + text = ( + f"Es sind noch rund {int(remaining)} Credits in dieser Abrechnungsperiode " + f"verfuegbar. Sind die Credits aufgebraucht, bleiben die Lagen lesbar, es " + f"lassen sich aber keine neuen Aktualisierungen mehr starten." + ) + + for user_id in user_ids: + await db.execute( + """INSERT INTO notifications (user_id, incident_id, type, title, text, icon, tenant_id, created_at) + VALUES (?, NULL, 'budget_warning', ?, ?, 'warning', ?, ?)""", + (user_id, title, text, organization_id, now), + ) + + logger.info( + f"Budget-Warnung fuer Org {organization_id} an {len(user_ids)} Nutzer " + f"({int(percent)} Prozent verbraucht)" + ) + + async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: """Prueft den Lizenzstatus einer Organisation. @@ -56,9 +176,19 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: # Felder zur weiteren Verwendung extrahieren lic_dict = dict(license_row) + + # Periodenwechsel nachholen, bevor irgendetwas geprueft wird. Sonst haengt + # ein Kunde mit monatlichem Kontingent im Nur-Lese-Modus fest, obwohl der + # neue Monat laengst begonnen hat. + lic_dict = await roll_credit_period(db, lic_dict) + unlimited_budget = bool(lic_dict.get("unlimited_budget")) credits_total = lic_dict.get("credits_total") 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" + # Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat. + credits_available = (credits_total or 0) + credits_carried # STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited if _staging_mode(): @@ -88,10 +218,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: except (ValueError, TypeError): pass - # Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited) + # Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited). + # Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag. budget_exceeded = False - if not unlimited_budget and credits_total and credits_total > 0: - if credits_used >= credits_total: + if not unlimited_budget and credits_available > 0: + if credits_used >= credits_available: budget_exceeded = True # Nutzerzahl pruefen @@ -110,10 +241,13 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: "current_users": current_users, "read_only": True, "read_only_reason": "budget_exceeded", - "message": "Token-Budget aufgebraucht", + "message": "Credits aufgebraucht", "unlimited_budget": False, "credits_total": credits_total, "credits_used": credits_used, + "credits_carried": credits_carried, + "credits_available": credits_available, + "credits_period": credits_period, } return { @@ -128,6 +262,9 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: "unlimited_budget": unlimited_budget, "credits_total": credits_total, "credits_used": credits_used, + "credits_carried": credits_carried, + "credits_available": credits_available, + "credits_period": credits_period, } @@ -152,21 +289,30 @@ async def charge_usage_to_tenant( tenant_id: int | None, usage, source: str, + incident_type: str | None = None, ) -> None: - """Verbucht Token-Verbrauch auf einen Tenant. + """Verbucht eine Aktion 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). + Zwei getrennte Vorgaenge. `token_usage_monthly` bekommt immer die echten + Tokenmengen und Kosten, das ist die interne Kostenkontrolle. Das Guthaben + der Lizenz wird je nach BILLING_MODE belastet. + + 'flat' zieht den festen Satz aus CREDIT_TARIFF ab. Der Kunde kann seinen + Verbrauch damit vorher ausrechnen, und eine spaetere Verbilligung + des Modell-Backends veraendert sein Kontingent nicht. + 'actual' zieht die echten Kosten geteilt durch cost_per_credit ab, also das + bisherige Verhalten. 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' + source: 'monitor' | 'analysis' | 'factcheck' | 'chat' | 'enhance' | 'globe' + incident_type: 'adhoc' | 'research', nur bei source='monitor' relevant 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. + Ausnahme ist der Periodenwechsel, der eine eigene Transaktion braucht. """ total_cost = getattr(usage, "total_cost_usd", None) if total_cost is None: @@ -179,9 +325,19 @@ async def charge_usage_to_tenant( ) return - if total_cost <= 0: - return + # Ohne echte Kosten gibt es nichts zu statistisch erfassen. Die Guthaben- + # Buchung laeuft im Pauschalmodus trotzdem, weil der Kunde die Aktion + # bezahlt und nicht unseren Einkauf. Auf lokalen Modellen ist total_cost 0. + if total_cost > 0: + await _record_usage_statistics(db, tenant_id, usage, source, total_cost) + await _charge_credits(db, tenant_id, source, incident_type, total_cost) + + +async def _record_usage_statistics( + db: aiosqlite.Connection, tenant_id: int, usage, source: str, total_cost: float +) -> None: + """Schreibt die echten Tokenmengen und Kosten nach token_usage_monthly.""" input_tokens = getattr(usage, "input_tokens", 0) output_tokens = getattr(usage, "output_tokens", 0) cache_creation = getattr(usage, "cache_creation_tokens", 0) @@ -214,24 +370,99 @@ async def charge_usage_to_tenant( ), ) + +async def _charge_credits( + db: aiosqlite.Connection, + tenant_id: int, + source: str, + incident_type: str | None, + total_cost: float, +) -> None: + """Belastet das Guthaben der aktiven Lizenz und prueft die Warnschwelle.""" lic_cursor = await db.execute( - "SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", + "SELECT * 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), - ) + lic_row = await lic_cursor.fetchone() + if not lic_row: + return + + lic = dict(lic_row) + if not lic.get("credits_total"): + return # Lizenz ohne Kontingent, nichts zu belasten + + # Periodenwechsel nachholen, bevor gebucht wird. Sonst landet der erste + # Verbrauch des neuen Monats noch auf dem alten Zaehler. + lic = await roll_credit_period(db, lic) + + key = _tariff_key(source, incident_type) + + if BILLING_MODE == "flat": + credits_consumed = CREDIT_TARIFF.get(key) + if credits_consumed is None: + # Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als + # stillschweigend gratis abzugeben. + logger.warning( + f"Kein Tarif fuer '{key}', falle auf tatsaechliche Kosten zurueck" + ) + credits_consumed = _actual_credits(lic, total_cost) + else: + credits_consumed = _actual_credits(lic, total_cost) + + if credits_consumed <= 0: + return + + await db.execute( + "UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE id = ?", + (round(credits_consumed, 2), lic["id"]), + ) + + used_new = (lic.get("credits_used") or 0) + credits_consumed + available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0) logger.info( - f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: " - f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits" + f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: " + f"${total_cost:.4f} -> {round(credits_consumed, 2)} Einheiten " + f"({round(used_new, 1)}/{round(available, 1)})" ) + await _check_budget_warning(db, tenant_id, lic, used_new, available) + + +def _actual_credits(lic: dict, total_cost: float) -> float: + """Echte Kosten in Einheiten umrechnen (Modus 'actual' und Rueckfallebene).""" + cost_per_credit = lic.get("cost_per_credit") + if not cost_per_credit or cost_per_credit <= 0: + return 0.0 + return total_cost / cost_per_credit + + +async def _check_budget_warning( + db: aiosqlite.Connection, tenant_id: int, lic: dict, used: float, available: float +) -> None: + """Meldet einmal je Periode, wenn die Warnschwelle erreicht ist. + + Die Schwelle steht als budget_warning_percent auf der Lizenz und war bisher + zwar als Spalte vorhanden, wurde aber nirgends ausgewertet. Ohne sie liefen + Kunden ohne Vorwarnung in den Nur-Lese-Modus. + """ + if available <= 0 or lic.get("budget_warning_sent"): + return + + threshold = lic.get("budget_warning_percent") or 80 + percent = (used / available) * 100 + if percent < threshold: + return + + await db.execute( + "UPDATE licenses SET budget_warning_sent = 1 WHERE id = ?", (lic["id"],) + ) + try: + await _notify_budget_warning(db, tenant_id, percent, max(0.0, available - used)) + except Exception as e: + # Eine fehlgeschlagene Benachrichtigung darf die Buchung nicht kippen. + logger.warning(f"Budget-Warnung konnte nicht zugestellt werden: {e}") + async def expire_licenses(db: aiosqlite.Connection): """Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen.""" diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 48b8d57..c5135dd 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -63,12 +63,12 @@ @@ -807,13 +807,13 @@ - + - + diff --git a/src/static/i18n/de.json b/src/static/i18n/de.json index 6421426..47eec8d 100644 --- a/src/static/i18n/de.json +++ b/src/static/i18n/de.json @@ -21,7 +21,7 @@ "action.restore": "Wiederherstellen", "action.budget_exceeded": "Budget aufgebraucht", "action.read_only": "Nur Lesezugriff", - "action.budget_exceeded_title": "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.", + "action.budget_exceeded_title": "Credits aufgebraucht. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.", "action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff", "sidebar.empty": "Keine Lagen vorhanden", "header.logout": "Abmelden", @@ -262,5 +262,8 @@ "chat.send_title": "Senden", "chat.send_aria": "Nachricht senden", "chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.", - "stats.articles_total": "Artikel gesamt" + "stats.articles_total": "Artikel gesamt", + "credits.label": "Credits", + "credits.label_monthly": "Credits diesen Monat", + "credits.of": "von" } diff --git a/src/static/i18n/en.json b/src/static/i18n/en.json index 4cd6b15..5391e2a 100644 --- a/src/static/i18n/en.json +++ b/src/static/i18n/en.json @@ -21,7 +21,7 @@ "action.restore": "Restore", "action.budget_exceeded": "Budget exhausted", "action.read_only": "Read-only", - "action.budget_exceeded_title": "Token budget exhausted. Please contact administration.", + "action.budget_exceeded_title": "Credits used up. Please contact your administrator to continue updating.", "action.read_only_title": "License does not permit write access", "sidebar.empty": "No situations yet", "header.logout": "Sign out", @@ -262,5 +262,8 @@ "chat.send_title": "Send", "chat.send_aria": "Send message", "chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.", - "stats.articles_total": "Articles total" + "stats.articles_total": "Articles total", + "credits.label": "Credits", + "credits.label_monthly": "Credits this month", + "credits.of": "of" } diff --git a/src/static/js/api.js b/src/static/js/api.js index cb3aee3..712eb11 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -102,10 +102,10 @@ const API = { const warningEl = document.getElementById('header-license-warning'); if (warningEl) { let text = 'Nur Lesezugriff'; - if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.'; - else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff'; - else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff'; - else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff'; + if (licStatus === 'budget_exceeded') text = 'Credits aufgebraucht, nur Lesezugriff. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.'; + else if (licStatus === 'expired') text = 'Lizenz abgelaufen, nur Lesezugriff'; + else if (licStatus === 'no_license') text = 'Keine aktive Lizenz, nur Lesezugriff'; + else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert, nur Lesezugriff'; warningEl.textContent = text; warningEl.classList.add('visible'); } diff --git a/src/static/js/app.js b/src/static/js/app.js index c445835..8a5021e 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -358,6 +358,16 @@ const App = { } const percentEl = document.getElementById("credits-percent"); if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend"; + + // Bezugszeitraum benennen. Ohne den Zusatz liest sich ein + // monatliches Kontingent wie ein Gesamtvorrat, der nie wiederkommt. + const labelEl = document.getElementById('credits-label'); + if (labelEl) { + const _tt = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb; + labelEl.textContent = user.credits_period === 'monthly' + ? _tt('credits.label_monthly', 'Credits diesen Monat') + : _tt('credits.label', 'Credits'); + } } // Dropdown Toggle diff --git a/src/static/studio.html b/src/static/studio.html index 7bc2d2e..70e0da3 100644 --- a/src/static/studio.html +++ b/src/static/studio.html @@ -561,7 +561,7 @@ - +