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
+17 -5
View File
@@ -5,6 +5,7 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
/**
* Lotse settings per tenant (lane L9): on/off = module toggle `lotse` (TenantModule, missing row = on),
@@ -38,19 +39,26 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
export async function getLotseSettings(ctx: ServiceCtx) {
assertCan(ctx, "tenant:manage");
const [enabled, s] = await Promise.all([isLotseEnabled(ctx), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } })]);
const [enabled, s, budget] = await Promise.all([
isLotseEnabled(ctx),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }),
getTokenBudget(ctx),
]);
const transcription = transcriptionConfig();
return {
enabled,
addressForm: toAddressForm(s?.lotseAddressForm),
draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL },
transcription,
budget: { ...budget, platformLimit: envMonthlyTokenLimit() },
};
}
export const lotseSettingsSchema = z.object({
enabled: z.boolean(),
addressForm: z.enum(["sie", "du", "neutral"]),
/** L10b: tenant token budget per month; null = platform default, 0 = unlimited, undefined = unchanged */
monthlyTokenLimit: z.number().int().min(0).max(1_000_000_000).nullable().optional(),
});
export type LotseSettingsInput = z.input<typeof lotseSettingsSchema>;
@@ -59,10 +67,13 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
const input = lotseSettingsSchema.parse(raw);
const addressForm = input.addressForm === "neutral" ? null : input.addressForm;
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true } });
const before = {
enabled: await isLotseEnabled(ctx),
addressForm: (await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }))?.lotseAddressForm ?? null,
addressForm: stored?.lotseAddressForm ?? null,
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
};
const monthlyTokenLimit = input.monthlyTokenLimit === undefined ? before.monthlyTokenLimit : input.monthlyTokenLimit;
await inTransaction(ctx, async (tx) => {
await tx.db.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
@@ -70,14 +81,15 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled },
});
const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } });
const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit };
if (existing) {
await tx.db.tenantSettings.update({ where: { id: existing.id }, data: { lotseAddressForm: addressForm } });
await tx.db.tenantSettings.update({ where: { id: existing.id }, data });
} else {
const tenant = await tx.db.tenant.findUnique({ where: { id: tx.tenantId }, select: { name: true } });
await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", lotseAddressForm: addressForm } });
await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", ...data } });
}
});
const after = { enabled: input.enabled, addressForm };
const after = { enabled: input.enabled, addressForm, monthlyTokenLimit };
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after });
return after;
}