diff --git a/src/main.py b/src/main.py index 84674c3..e780557 100644 --- a/src/main.py +++ b/src/main.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from config import STATIC_DIR, PORT -from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper +from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing logging.basicConfig( level=logging.INFO, @@ -44,6 +44,7 @@ app.include_router(token_usage.router) app.include_router(audit.router) app.include_router(translation.router) app.include_router(x_scraper.router) +app.include_router(pricing.router) # --- Statische Dateien --- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/src/routers/pricing.py b/src/routers/pricing.py new file mode 100644 index 0000000..5b10a96 --- /dev/null +++ b/src/routers/pricing.py @@ -0,0 +1,66 @@ +"""Preis-Empfehlung aus den echten API-Kosten der letzten Tage. + +Reine Leseabfrage. Die Empfehlung ist eine betreiberweite Kennzahl (Median der +tatsaechlichen Refresh-Kosten je Lagentyp), deshalb wird die geteilte Live-DB +read-only gelesen, unabhaengig vom DB_PATH des jeweiligen Portals. +""" +import logging +import math +import os +import statistics +from collections import defaultdict +from datetime import datetime, timedelta + +import aiosqlite +from fastapi import APIRouter, Depends +from auth import get_current_admin + +logger = logging.getLogger("verwaltung.pricing") +router = APIRouter(prefix="/api/pricing", tags=["Pricing"]) + +COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db") +USD_PER_CREDIT = 0.20 + + +@router.get("/cost-basis") +async def cost_basis(days: int = 30, admin=Depends(get_current_admin)): + """Median der echten Refresh-Kosten je Lagentyp und daraus abgeleitete + Credit-Empfehlung (Median USD geteilt durch 0,20 USD, aufgerundet).""" + days = max(1, min(365, days)) + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + result = {"days": days, "usd_per_credit": USD_PER_CREDIT, "typen": {}} + + try: + db = await aiosqlite.connect(f"file:{COST_BASIS_DB}?mode=ro", uri=True) + try: + cursor = await db.execute( + "SELECT i.type, r.total_cost_usd " + "FROM refresh_log r JOIN incidents i ON i.id = r.incident_id " + "WHERE r.status = 'completed' AND r.started_at >= ?", + (cutoff,), + ) + rows = await cursor.fetchall() + finally: + await db.close() + except Exception as e: + logger.warning("cost-basis DB-Fehler: %s", e) + result["fehler"] = "Kostendaten nicht verfuegbar" + return result + + gruppen = defaultdict(list) + for typ, kosten in rows: + gruppen[typ or "adhoc"].append(kosten or 0.0) + + for db_key, label in (("adhoc", "live"), ("research", "research")): + werte = gruppen.get(db_key, []) + if werte: + median = statistics.median(werte) + result["typen"][label] = { + "median_usd": round(median, 2), + "n": len(werte), + "empfohlen": int(math.ceil(median / USD_PER_CREDIT)) if median > 0 else 0, + } + else: + result["typen"][label] = {"median_usd": None, "n": 0, "empfohlen": None} + + return result diff --git a/src/static/js/app.js b/src/static/js/app.js index 694f08b..fa4f116 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -1051,6 +1051,6 @@ document.addEventListener("DOMContentLoaded", () => { // --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) --- (function () { var s = document.createElement("script"); - s.src = "/static/js/rechner.js?v=20260724b"; + s.src = "/static/js/rechner.js?v=20260724c"; document.head.appendChild(s); })(); diff --git a/src/static/js/rechner.js b/src/static/js/rechner.js index e27eae9..4d652ab 100644 --- a/src/static/js/rechner.js +++ b/src/static/js/rechner.js @@ -3,15 +3,17 @@ // Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal. // Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst, // damit dashboard.html unveraendert bleiben kann. +// Saetze sind ueber Regler frei einstellbar, mit Empfehlung aus den echten +// API-Kosten der letzten 30 Tage (GET /api/pricing/cost-basis). (function () { - var SATZ_LIVE = 45; - var SATZ_RECHERCHE_NEU = 120; - var SATZ_AKTUALISIERUNG = 40; - var SATZ_NEBEN = 1; var USD = 0.20; var MINIMUM_TAKT_MINUTEN = 30; + // Frei einstellbare Saetze (Credits je Aktion). + var saetze = { live: 45, research: 40, neben: 1 }; + function rechercheNeu() { return saetze.research * 3; } + var EINHEITEN = [ { wert: 1, text: "Minuten" }, { wert: 60, text: "Stunden" }, @@ -28,6 +30,7 @@ }; function fmt(n) { return Math.round(n).toLocaleString("de-DE"); } + function fmtUsd(n) { return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"; } function neuerPosten(typ) { if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 }; @@ -49,14 +52,16 @@ var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit); laeufe = Math.max(1, Math.floor(p.tage * 1440 / intervallMin)); } - return (p.anzahl * laeufe + p.manuell) * SATZ_LIVE; + return (p.anzahl * laeufe + p.manuell) * saetze.live; } if (typ === "recherche") { - return p.anzahl * SATZ_RECHERCHE_NEU + p.vertiefungen * SATZ_AKTUALISIERUNG; + return p.anzahl * rechercheNeu() + p.vertiefungen * saetze.research; } - return (p.auto ? autoAufrufe() : p.aufrufe) * SATZ_NEBEN; + return (p.auto ? autoAufrufe() : p.aufrufe) * saetze.neben; } + // ---------- Posten-Felder ---------- + function feldHtml(typ, i, name, labelText, hinweis, wert, min, max) { var id = "vr-" + typ + "-" + i + "-" + name; return '