feat(rechner): Saetze per Regler einstellbar mit Empfehlung aus 30-Tage-API-Kosten

Neuer Router pricing.py (GET /api/pricing/cost-basis), liest die geteilte
Live-DB read-only und liefert Median-Kosten je Lagentyp plus Credit-Empfehlung.
main.py um den Router erweitert. Frontend rechner.js mit Reglern statt fester
Saetze und Empfehlungsanzeige je Aktion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude
2026-07-24 20:19:58 +00:00
Ursprung 6ce42a8809
Commit 607da8f43d
4 geänderte Dateien mit 206 neuen und 30 gelöschten Zeilen

Datei anzeigen

@@ -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")

66
src/routers/pricing.py Normale Datei
Datei anzeigen

@@ -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

Datei anzeigen

@@ -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);
})();

Datei anzeigen

@@ -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 '<div class="vr-field"><label for="' + id + '">' + labelText +
@@ -125,6 +130,68 @@
}).join("");
}
// ---------- Regler (frei einstellbare Saetze) ----------
function reglerHtml(satz, kicker, label, min, max) {
return '<div class="vr-regler">' +
'<div class="vr-regler-kicker">' + kicker + "</div>" +
'<div class="vr-regler-label">' + label + "</div>" +
'<div class="vr-regler-wert"><b id="vr-wert-' + satz + '">' + saetze[satz] + "</b> <small>Credits</small></div>" +
'<input type="range" id="vr-slider-' + satz + '" data-satz="' + satz + '" min="' + min + '" max="' + max + '" value="' + saetze[satz] + '">' +
(satz === "research" ? '<div class="vr-note" id="vr-note-research"></div>' : "") +
'<div class="vr-empf" id="vr-empf-' + satz + '">' +
(satz === "neben" ? "Nebenverbrauch real 0,02 bis 0,10 $ je Aufruf. 1 Credit ist die kleinste sinnvolle Einheit." : "Empfehlung wird geladen…") +
"</div>" +
"</div>";
}
function updateWerte() {
var wL = document.getElementById("vr-wert-live"); if (wL) wL.textContent = saetze.live;
var wR = document.getElementById("vr-wert-research"); if (wR) wR.textContent = saetze.research;
var wN = document.getElementById("vr-wert-neben"); if (wN) wN.textContent = saetze.neben;
var note = document.getElementById("vr-note-research");
if (note) note.textContent = "Anlegen einer Recherche = 3 × " + saetze.research + " = " + rechercheNeu() + " Credits";
}
var empfDaten = null;
function renderEmpfehlung() {
if (!empfDaten) return;
["live", "research"].forEach(function (satz) {
var el = document.getElementById("vr-empf-" + satz);
if (!el) return;
var t = (empfDaten.typen || {})[satz];
if (!t || t.empfohlen == null || !t.median_usd) {
el.textContent = "Keine Kostendaten der letzten " + empfDaten.days + " Tage.";
return;
}
el.innerHTML = "Empfohlen <b>" + t.empfohlen + "</b> · Median " + fmtUsd(t.median_usd) +
" aus " + fmt(t.n) + " Läufen (" + empfDaten.days + " Tage) " +
'<button type="button" class="vr-uebernehmen" data-uebernehmen="' + satz + '" data-wert="' + t.empfohlen + '">übernehmen</button>';
});
}
function ladeEmpfehlung() {
var token = null;
try { token = localStorage.getItem("token"); } catch (e) {}
fetch("/api/pricing/cost-basis?days=30", {
headers: token ? { "Authorization": "Bearer " + token } : {}
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (data) {
empfDaten = data;
renderEmpfehlung();
}).catch(function () {
["live", "research"].forEach(function (satz) {
var el = document.getElementById("vr-empf-" + satz);
if (el) el.textContent = "Empfehlung derzeit nicht verfügbar.";
});
});
}
// ---------- Gesamtrechnung ----------
function calc() {
var total = 0;
var zeilen = [];
@@ -140,7 +207,7 @@
var totalEl = document.getElementById("vr-total");
if (totalEl) totalEl.innerHTML = fmt(total) + "<small>Credits insgesamt</small>";
var usdEl = document.getElementById("vr-usd");
if (usdEl) usdEl.textContent = (total * USD).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $";
if (usdEl) usdEl.textContent = fmtUsd(total * USD);
syncNebenAnzeige();
}
@@ -156,6 +223,23 @@
});
}
function alleSummenNeu() {
BLOCK_REIHENFOLGE.forEach(function (typ) {
bloecke[typ].forEach(function (p, i) {
var wrap = document.querySelector("#vr-liste-" + typ + ' .vr-posten[data-i="' + i + '"]');
if (!wrap) return;
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits(typ, p));
});
});
}
function satzGesetzt() {
updateWerte();
alleSummenNeu();
calc();
}
function wertUebernehmen(e) {
var wrap = e.target.closest(".vr-posten");
var name = e.target.getAttribute("data-feld");
@@ -197,16 +281,24 @@
calc();
}
// ---------- Styles ----------
var STYLES =
".vr-kopf { margin-bottom: 16px; }" +
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" +
".vr-karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
".vr-karte { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
".vr-karte-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
".vr-karte-titel { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
".vr-karte-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; }" +
".vr-karte-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" +
".vr-karte-hinweis { font-size: 12px; color: var(--text-muted); margin-top: 6px; }" +
".vr-kopf p { margin: 0; font-size: 13px; color: var(--text-secondary); }" +
".vr-regler-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
".vr-regler { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
".vr-regler-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
".vr-regler-label { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
".vr-regler-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; margin-bottom: 8px; }" +
".vr-regler-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" +
".vr-regler input[type=range] { width: 100%; accent-color: var(--accent); cursor: pointer; }" +
".vr-note { font-size: 12px; color: var(--text-secondary); margin-top: 6px; }" +
".vr-empf { font-size: 12px; color: var(--text-muted); margin-top: 8px; line-height: 1.5; }" +
".vr-empf b { color: var(--accent); }" +
".vr-uebernehmen { margin-left: 4px; padding: 2px 9px; font-family: inherit; font-size: 11px; background: var(--bg-tertiary); color: var(--accent); border: 1px solid var(--accent); border-radius: 999px; cursor: pointer; }" +
".vr-uebernehmen:hover { background: var(--accent); color: var(--bg-primary); }" +
".vr-basis { font-size: 13px; color: var(--text-secondary); margin: 0 0 20px; }" +
".vr-calc { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }" +
"@media (max-width: 900px) { .vr-calc { grid-template-columns: 1fr; } }" +
@@ -244,24 +336,17 @@
".vr-row span:last-child { font-weight: 600; font-variant-numeric: tabular-nums; }" +
".vr-sep { margin: 12px 0; border: none; border-top: 1px solid var(--border); }";
function karteHtml(kicker, titel, credits, hinweis) {
return '<div class="vr-karte">' +
'<div class="vr-karte-kicker">' + kicker + "</div>" +
'<div class="vr-karte-titel">' + titel + "</div>" +
'<div class="vr-karte-wert">' + credits + " <small>Credits</small></div>" +
'<div class="vr-karte-hinweis">' + hinweis + "</div>" +
"</div>";
}
// ---------- Aufbau ----------
function sektionHtml() {
return '<div class="vr-kopf">' +
"<h2>Verbrauchsrechner</h2>" +
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
"</div>" +
'<div class="vr-karten">' +
karteHtml("Live-Monitoring", "Jeder Lauf", 45, "Anlegen und jede Aktualisierung einer Live-Lage") +
karteHtml("Recherche", "Anlegen", 120, "Der erste Lauf fährt automatisch drei Durchläufe zu je 40") +
karteHtml("Recherche", "Jede Aktualisierung", 40, "Wird von Hand gestartet und zählt als einzelner Durchlauf") +
karteHtml("Nebenkosten", "Je Aufruf", 1, "Chat oder Beschreibungs-Assistent") +
'<div class="vr-regler-grid">' +
reglerHtml("live", "Live-Monitoring", "Jeder Lauf", 1, 100) +
reglerHtml("research", "Recherche", "Je Durchlauf", 1, 100) +
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
"</div>" +
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
'<div class="vr-calc">' +
@@ -320,7 +405,14 @@
sektion.classList.remove("active");
});
// Regler-Slider live in die Saetze uebernehmen.
sektion.addEventListener("input", function (e) {
if (e.target.type === "range") {
var satz = e.target.getAttribute("data-satz");
saetze[satz] = parseInt(e.target.value, 10) || 1;
satzGesetzt();
return;
}
if (e.target.tagName === "SELECT") return;
wertUebernehmen(e);
});
@@ -332,6 +424,21 @@
});
sektion.addEventListener("click", function (e) {
var uebBtn = e.target.closest("[data-uebernehmen]");
if (uebBtn) {
var s = uebBtn.getAttribute("data-uebernehmen");
var w = parseInt(uebBtn.getAttribute("data-wert"), 10);
if (w > 0) {
saetze[s] = w;
var slider = document.getElementById("vr-slider-" + s);
if (slider) {
if (w > parseInt(slider.max, 10)) slider.max = w;
slider.value = w;
}
satzGesetzt();
}
return;
}
var taktBtn = e.target.closest("[data-takt]");
if (taktBtn) {
var wrapT = taktBtn.closest(".vr-posten");
@@ -363,10 +470,12 @@
}
});
updateWerte();
renderBlock("live");
renderBlock("recherche");
renderBlock("neben");
calc();
ladeEmpfehlung();
}
if (document.readyState === "loading") {