L10b Betrieb & Aufräumen: Lotse-Betrieb – Aufbewahrung KI-Protokoll und Token-Kontingent

Aufräumpunkt k (Spec §31):
- Aufbewahrung: services/lotse/retention.ts leert input/output und createdById von
  AiGeneration-Einträgen älter als AI_GENERATION_RETENTION_DAYS (Default 180), Metadaten bleiben,
  Audit je Mandant. Queue/Processor ai-retention, täglicher BullMQ-Job-Scheduler beim Start des
  craftvia-worker.
- Kontingent: services/lotse/budget.ts (Tokens ein+aus je Kalendermonat, TenantSettings-Wert vor
  Env AI_MONTHLY_TOKEN_LIMIT, 0 = unbegrenzt). Lotse-Entwurf und Sprachnotiz-Zusammenfassung
  → blocked budget_exceeded mit Klartext; Import-Extraktion fällt auf manuelle Erfassung zurück
  (Hinweis ai_budget_exceeded). /settings/lotse: Kontingent setzen, Verbrauch anzeigen.
- scripts/test-betrieb-audit.ts: Audit nach Commit/Rollback/verschachtelt, Merge atomar und in
  äußerer Transaktion, Audit „read", Aufbewahrung (Frist, Metadaten, Idempotenz, Mandant B),
  Kontingent (Mandant/Env/Vormonat/unbegrenzt, Rollen, Audit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent 8aedc642ca
commit b0aedb5d23
19 changed files with 437 additions and 15 deletions
+33 -2
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { PageHead } from "@/components/mockup-ui";
@@ -27,8 +27,9 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
const ctx = await readCtx();
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]);
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") };
const nf = new Intl.NumberFormat(locale);
return (
<main className="flex-1 p-4 md:p-6">
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
))}
</div>
</fieldset>
<div>
<label htmlFor="monthlyTokenLimit" className="text-sm font-semibold">
{t("settings.budget")}
</label>
<p className="text-[12px] text-muted-foreground">
{t("settings.budgetHint", { platform: s.budget.platformLimit > 0 ? nf.format(s.budget.platformLimit) : t("settings.budgetUnlimited") })}
</p>
<input
id="monthlyTokenLimit"
name="monthlyTokenLimit"
type="number"
inputMode="numeric"
min={0}
step={1000}
defaultValue={s.budget.tenantLimit ?? ""}
className="mt-2 h-11 w-full max-w-60 rounded-lg border border-input bg-background px-3 text-sm"
/>
<p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-muted-foreground">
{t("settings.budgetUsage", {
since: new Date(s.budget.periodStart).toLocaleDateString(locale, { timeZone: "UTC" }),
used: nf.format(s.budget.used),
limit: s.budget.limit > 0 ? nf.format(s.budget.limit) : t("settings.budgetUnlimited"),
})}
{s.budget.exceeded && (
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t("settings.budgetExceeded")}
</span>
)}
</p>
</div>
<Button type="submit" className="min-h-11">
{t("settings.save")}
</Button>