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 fastapi.responses import FileResponse
from config import STATIC_DIR, PORT 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -44,6 +44,7 @@ app.include_router(token_usage.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(translation.router) app.include_router(translation.router)
app.include_router(x_scraper.router) app.include_router(x_scraper.router)
app.include_router(pricing.router)
# --- Statische Dateien --- # --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") 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) --- // --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
(function () { (function () {
var s = document.createElement("script"); 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); document.head.appendChild(s);
})(); })();

Datei anzeigen

@@ -3,15 +3,17 @@
// Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal. // Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal.
// Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst, // Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst,
// damit dashboard.html unveraendert bleiben kann. // 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 () { (function () {
var SATZ_LIVE = 45;
var SATZ_RECHERCHE_NEU = 120;
var SATZ_AKTUALISIERUNG = 40;
var SATZ_NEBEN = 1;
var USD = 0.20; var USD = 0.20;
var MINIMUM_TAKT_MINUTEN = 30; 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 = [ var EINHEITEN = [
{ wert: 1, text: "Minuten" }, { wert: 1, text: "Minuten" },
{ wert: 60, text: "Stunden" }, { wert: 60, text: "Stunden" },
@@ -28,6 +30,7 @@
}; };
function fmt(n) { return Math.round(n).toLocaleString("de-DE"); } 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) { function neuerPosten(typ) {
if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 }; if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 };
@@ -49,14 +52,16 @@
var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit); var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit);
laeufe = Math.max(1, Math.floor(p.tage * 1440 / intervallMin)); 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") { 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) { function feldHtml(typ, i, name, labelText, hinweis, wert, min, max) {
var id = "vr-" + typ + "-" + i + "-" + name; var id = "vr-" + typ + "-" + i + "-" + name;
return '<div class="vr-field"><label for="' + id + '">' + labelText + return '<div class="vr-field"><label for="' + id + '">' + labelText +
@@ -125,6 +130,68 @@
}).join(""); }).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() { function calc() {
var total = 0; var total = 0;
var zeilen = []; var zeilen = [];
@@ -140,7 +207,7 @@
var totalEl = document.getElementById("vr-total"); var totalEl = document.getElementById("vr-total");
if (totalEl) totalEl.innerHTML = fmt(total) + "<small>Credits insgesamt</small>"; if (totalEl) totalEl.innerHTML = fmt(total) + "<small>Credits insgesamt</small>";
var usdEl = document.getElementById("vr-usd"); 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(); 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) { function wertUebernehmen(e) {
var wrap = e.target.closest(".vr-posten"); var wrap = e.target.closest(".vr-posten");
var name = e.target.getAttribute("data-feld"); var name = e.target.getAttribute("data-feld");
@@ -197,16 +281,24 @@
calc(); calc();
} }
// ---------- Styles ----------
var STYLES = var STYLES =
".vr-kopf { margin-bottom: 16px; }" + ".vr-kopf { margin-bottom: 16px; }" +
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" + ".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-kopf p { margin: 0; font-size: 13px; color: var(--text-secondary); }" +
".vr-karte { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" + ".vr-regler-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
".vr-karte-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" + ".vr-regler { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
".vr-karte-titel { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" + ".vr-regler-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
".vr-karte-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; }" + ".vr-regler-label { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
".vr-karte-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" + ".vr-regler-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; margin-bottom: 8px; }" +
".vr-karte-hinweis { font-size: 12px; color: var(--text-muted); margin-top: 6px; }" + ".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-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; }" + ".vr-calc { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }" +
"@media (max-width: 900px) { .vr-calc { grid-template-columns: 1fr; } }" + "@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-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); }"; ".vr-sep { margin: 12px 0; border: none; border-top: 1px solid var(--border); }";
function karteHtml(kicker, titel, credits, hinweis) { // ---------- Aufbau ----------
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>";
}
function sektionHtml() { function sektionHtml() {
return '<div class="vr-kopf">' + return '<div class="vr-kopf">' +
"<h2>Verbrauchsrechner</h2>" + "<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>" +
'<div class="vr-karten">' + '<div class="vr-regler-grid">' +
karteHtml("Live-Monitoring", "Jeder Lauf", 45, "Anlegen und jede Aktualisierung einer Live-Lage") + reglerHtml("live", "Live-Monitoring", "Jeder Lauf", 1, 100) +
karteHtml("Recherche", "Anlegen", 120, "Der erste Lauf fährt automatisch drei Durchläufe zu je 40") + reglerHtml("research", "Recherche", "Je Durchlauf", 1, 100) +
karteHtml("Recherche", "Jede Aktualisierung", 40, "Wird von Hand gestartet und zählt als einzelner Durchlauf") + reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
karteHtml("Nebenkosten", "Je Aufruf", 1, "Chat oder Beschreibungs-Assistent") +
"</div>" + "</div>" +
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' + '<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
'<div class="vr-calc">' + '<div class="vr-calc">' +
@@ -320,7 +405,14 @@
sektion.classList.remove("active"); sektion.classList.remove("active");
}); });
// Regler-Slider live in die Saetze uebernehmen.
sektion.addEventListener("input", function (e) { 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; if (e.target.tagName === "SELECT") return;
wertUebernehmen(e); wertUebernehmen(e);
}); });
@@ -332,6 +424,21 @@
}); });
sektion.addEventListener("click", function (e) { 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]"); var taktBtn = e.target.closest("[data-takt]");
if (taktBtn) { if (taktBtn) {
var wrapT = taktBtn.closest(".vr-posten"); var wrapT = taktBtn.closest(".vr-posten");
@@ -363,10 +470,12 @@
} }
}); });
updateWerte();
renderBlock("live"); renderBlock("live");
renderBlock("recherche"); renderBlock("recherche");
renderBlock("neben"); renderBlock("neben");
calc(); calc();
ladeEmpfehlung();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {