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
@@ -0,0 +1,11 @@
import { purgeExpiredAiGenerations } from "@/server/services/lotse/retention";
/**
* Daily retention job for the AI log (L10b, Spec §31). Scheduled by the craftvia worker
* (`scheduleRecurringJobs`); the payload carries no tenant — the service iterates all tenants and
* writes through dbForTenant.
*/
export async function process(): Promise<void> {
const res = await purgeExpiredAiGenerations();
console.info(`[ai-retention] ${res.pseudonymised} entries older than ${res.days} days pseudonymised (${res.tenants} tenants)`);
}
+1
View File
@@ -12,6 +12,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
transcription: () => import("./transcription").then((m) => m.process),
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
};
/** Inline fallback when no Redis is available (dev/demo). */
+19
View File
@@ -12,6 +12,7 @@ export const JOB_QUEUES = {
transcription: "transcription",
reportPdf: "report-pdf",
imageDerivatives: "image-derivatives",
aiRetention: "ai-retention",
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -80,6 +81,24 @@ export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promi
return true;
}
/**
* Recurring jobs (L10b), registered once by the craftvia worker at start. BullMQ job schedulers
* are idempotent per id, so several worker replicas do not create duplicates.
* - ai-retention: daily pseudonymisation of AI log contents (services/lotse/retention.ts)
*/
export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
const q = new Queue<JobPayload>(JOB_QUEUES.aiRetention, { connection });
try {
await q.upsertJobScheduler(
"ai-retention-daily",
{ every: 24 * 60 * 60 * 1000 },
{ name: JOB_QUEUES.aiRetention, data: { tenantId: "*", entityId: "retention" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } },
);
} finally {
await q.close();
}
}
export async function closeJobQueues(): Promise<void> {
await Promise.all([...queues.values()].map((q) => q.close()));
queues.clear();