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:
@@ -170,7 +170,8 @@
|
|||||||
"email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.",
|
"email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.",
|
||||||
"phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.",
|
"phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.",
|
||||||
"end_before_start": "{field}: Ende liegt vor dem Beginn.",
|
"end_before_start": "{field}: Ende liegt vor dem Beginn.",
|
||||||
"manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen."
|
"manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen.",
|
||||||
|
"ai_budget_exceeded": "Das monatliche KI-Kontingent ist aufgebraucht – deshalb keine automatische Erkennung."
|
||||||
},
|
},
|
||||||
"positions": {
|
"positions": {
|
||||||
"name": "Bezeichnung",
|
"name": "Bezeichnung",
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
"no_transcript": "Die Sprachnotiz hat noch keinen Text.",
|
"no_transcript": "Die Sprachnotiz hat noch keinen Text.",
|
||||||
"pending": "Die Transkription läuft noch.",
|
"pending": "Die Transkription läuft noch.",
|
||||||
"conflict": "Inzwischen geändert. Bitte Seite neu laden.",
|
"conflict": "Inzwischen geändert. Bitte Seite neu laden.",
|
||||||
"invalid": "Bitte Eingaben prüfen."
|
"invalid": "Bitte Eingaben prüfen.",
|
||||||
|
"budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht. Bitte den Bericht selbst schreiben oder das Büro fragen."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"back": "Einstellungen",
|
"back": "Einstellungen",
|
||||||
@@ -96,6 +97,11 @@
|
|||||||
"du": "du"
|
"du": "du"
|
||||||
},
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"budget": "Monatliches KI-Kontingent (Tokens)",
|
||||||
|
"budgetHint": "Leer = Vorgabe der Plattform ({platform}). 0 = unbegrenzt. Ist das Kontingent aufgebraucht, bereitet der Lotse bis Monatsende nichts mehr vor.",
|
||||||
|
"budgetUnlimited": "unbegrenzt",
|
||||||
|
"budgetUsage": "Verbraucht seit {since}: {used} von {limit}",
|
||||||
|
"budgetExceeded": "Kontingent aufgebraucht",
|
||||||
"dataTitle": "Welche Daten an wen gehen",
|
"dataTitle": "Welche Daten an wen gehen",
|
||||||
"draftProvider": "Berichtsentwurf und Zusammenfassung",
|
"draftProvider": "Berichtsentwurf und Zusammenfassung",
|
||||||
"transcriptionProvider": "Transkription von Sprachnotizen",
|
"transcriptionProvider": "Transkription von Sprachnotizen",
|
||||||
|
|||||||
@@ -170,7 +170,8 @@
|
|||||||
"email_invalid": "{field}: e-mail address format is invalid.",
|
"email_invalid": "{field}: e-mail address format is invalid.",
|
||||||
"phone_invalid": "{field}: phone number format is invalid.",
|
"phone_invalid": "{field}: phone number format is invalid.",
|
||||||
"end_before_start": "{field}: end is before start.",
|
"end_before_start": "{field}: end is before start.",
|
||||||
"manual_entry": "Automatic recognition is not available. Please enter manually."
|
"manual_entry": "Automatic recognition is not available. Please enter manually.",
|
||||||
|
"ai_budget_exceeded": "The monthly AI allowance is used up – therefore no automatic recognition."
|
||||||
},
|
},
|
||||||
"positions": {
|
"positions": {
|
||||||
"name": "Description",
|
"name": "Description",
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
"no_transcript": "The voice note has no text yet.",
|
"no_transcript": "The voice note has no text yet.",
|
||||||
"pending": "The transcription is still running.",
|
"pending": "The transcription is still running.",
|
||||||
"conflict": "Changed in the meantime. Please reload the page.",
|
"conflict": "Changed in the meantime. Please reload the page.",
|
||||||
"invalid": "Please check your input."
|
"invalid": "Please check your input.",
|
||||||
|
"budget_exceeded": "This business has used up its monthly AI allowance. Please write the report yourself or ask the office."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"back": "Settings",
|
"back": "Settings",
|
||||||
@@ -96,6 +97,11 @@
|
|||||||
"du": "Informal (du)"
|
"du": "Informal (du)"
|
||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"budget": "Monthly AI allowance (tokens)",
|
||||||
|
"budgetHint": "Empty = platform default ({platform}). 0 = unlimited. Once used up, Lotse prepares nothing until the end of the month.",
|
||||||
|
"budgetUnlimited": "unlimited",
|
||||||
|
"budgetUsage": "Used since {since}: {used} of {limit}",
|
||||||
|
"budgetExceeded": "Allowance used up",
|
||||||
"dataTitle": "Which data goes where",
|
"dataTitle": "Which data goes where",
|
||||||
"draftProvider": "Report draft and summary",
|
"draftProvider": "Report draft and summary",
|
||||||
"transcriptionProvider": "Voice note transcription",
|
"transcriptionProvider": "Voice note transcription",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Worker } from "bullmq";
|
import { Worker } from "bullmq";
|
||||||
import { JOB_QUEUES, workerConnection, closeJobQueues, type JobPayload } from "../src/server/jobs/queues";
|
import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues";
|
||||||
import { PROCESSORS } from "../src/server/jobs/processors";
|
import { PROCESSORS } from "../src/server/jobs/processors";
|
||||||
|
|
||||||
/** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
|
/** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
|
||||||
@@ -24,6 +24,11 @@ async function main() {
|
|||||||
workers.push(w);
|
workers.push(w);
|
||||||
console.info(`[worker] listening on ${name}`);
|
console.info(`[worker] listening on ${name}`);
|
||||||
}
|
}
|
||||||
|
// L10b: recurring jobs (AI log retention); a scheduling failure must not stop the queue workers
|
||||||
|
await scheduleRecurringJobs(connection).then(
|
||||||
|
() => console.info("[worker] recurring jobs scheduled"),
|
||||||
|
(err) => console.error("[worker] scheduling recurring jobs failed:", (err as Error).message),
|
||||||
|
);
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
await Promise.all(workers.map((w) => w.close()));
|
await Promise.all(workers.map((w) => w.close()));
|
||||||
await closeJobQueues();
|
await closeJobQueues();
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Lane L10b „Betrieb & Aufräumen" — Transaktionen, Audit und Lotse-Betrieb:
|
||||||
|
// h) Audit-Einträge innerhalb von inTransaction erst nach dem Commit (Rollback → keine Einträge,
|
||||||
|
// außer „denied"; verschachtelt; aufgeschoben)
|
||||||
|
// d) mergeCustomers über inTransaction (atomar, in äußere Transaktion einbettbar)
|
||||||
|
// e) Audit-Aktion „read" für die Notdienst-Kundensuche
|
||||||
|
// k) KI-Protokoll: Aufbewahrungsfrist (Pseudonymisierung) + monatliches Token-Kontingent je Mandant
|
||||||
|
// Jeweils mit Mandantentrennung (B) und Rollen (Monteur → forbidden).
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-betrieb-audit.ts (lokale Postgres-DB aus .env)
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { prisma, dbForTenant } from "../src/server/db";
|
||||||
|
import { writeAuditLog } from "../src/server/audit";
|
||||||
|
import { inTransaction } from "../src/server/services/context";
|
||||||
|
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||||
|
import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup";
|
||||||
|
import { aiGenerationRetentionDays, purgeExpiredAiGenerations } from "../src/server/services/lotse/retention";
|
||||||
|
import { assertTokenBudget, getTokenBudget } from "../src/server/services/lotse/budget";
|
||||||
|
import { getLotseSettings, updateLotseSettings } from "../src/server/services/lotse/settings";
|
||||||
|
import { PROCESSORS } from "../src/server/jobs/processors";
|
||||||
|
import { ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l10b-audit-a";
|
||||||
|
const SLUG_B = "zz-l10b-audit-b";
|
||||||
|
const DOMAIN = "@zz-l10b-audit.test";
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
for (const slug of [SLUG_A, SLUG_B]) {
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } });
|
||||||
|
if (!tenant) continue;
|
||||||
|
const where = { tenantId: tenant.id };
|
||||||
|
await prisma.auditLog.deleteMany({ where });
|
||||||
|
await prisma.aiGeneration.deleteMany({ where });
|
||||||
|
await prisma.workOrder.deleteMany({ where });
|
||||||
|
await prisma.site.deleteMany({ where });
|
||||||
|
await prisma.contact.deleteMany({ where });
|
||||||
|
await prisma.customer.deleteMany({ where });
|
||||||
|
await prisma.tenantModule.deleteMany({ where });
|
||||||
|
await prisma.tenantSettings.deleteMany({ where });
|
||||||
|
await prisma.user.deleteMany({ where });
|
||||||
|
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||||
|
}
|
||||||
|
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function user(tenantId: string, local: string) {
|
||||||
|
const email = `${local}${DOMAIN}`;
|
||||||
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||||
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditCount = (tenantId: string, entity: string, entityId?: string, action?: string) =>
|
||||||
|
prisma.auditLog.count({ where: { tenantId, entity, ...(entityId ? { entityId } : {}), ...(action ? { action } : {}) } });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await cleanup();
|
||||||
|
const tenantA = await prisma.tenant.create({ data: { name: "L10b Audit A", slug: SLUG_A } });
|
||||||
|
const tenantB = await prisma.tenant.create({ data: { name: "L10b Audit B", slug: SLUG_B } });
|
||||||
|
const adminA = await user(tenantA.id, "admin-a");
|
||||||
|
const techA = await user(tenantA.id, "tech-a");
|
||||||
|
const adminB = await user(tenantB.id, "admin-b");
|
||||||
|
const ctxAdminA = ctxFor(tenantA.id, adminA.id, "tenant-admin");
|
||||||
|
const ctxTechA = ctxFor(tenantA.id, techA.id, "technician");
|
||||||
|
const ctxAdminB = ctxFor(tenantB.id, adminB.id, "tenant-admin");
|
||||||
|
await prisma.tenantSettings.create({ data: { tenantId: tenantA.id, orgName: "A" } });
|
||||||
|
await prisma.tenantSettings.create({ data: { tenantId: tenantB.id, orgName: "B" } });
|
||||||
|
|
||||||
|
const customer = (tenantId: string, companyName: string) => prisma.customer.create({ data: { tenantId, companyName, city: "Kiel" } });
|
||||||
|
const site = (tenantId: string, customerId: string) =>
|
||||||
|
prisma.site.create({ data: { tenantId, customerId, name: "Halle", street: "Weg", houseNumber: "1", postalCode: "24103", city: "Kiel" } });
|
||||||
|
|
||||||
|
console.log("\n— h) Audit nach Commit —");
|
||||||
|
const probe = await customer(tenantA.id, "Probe GmbH");
|
||||||
|
await inTransaction(ctxAdminA, async (tx) => {
|
||||||
|
await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Lübeck" } });
|
||||||
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "commit" });
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 0, "innerhalb der Transaktion noch kein Audit-Eintrag (aufgeschoben)");
|
||||||
|
});
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 1, "nach Commit: Audit-Eintrag geschrieben");
|
||||||
|
|
||||||
|
await inTransaction(ctxAdminA, async (tx) => {
|
||||||
|
await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Flensburg" } });
|
||||||
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "rollback" });
|
||||||
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "denied", entity: "zz_l10b_tx", entityId: "rollback-denied" });
|
||||||
|
throw new Error("zz rollback");
|
||||||
|
}).catch(() => undefined);
|
||||||
|
ok((await prisma.customer.findUniqueOrThrow({ where: { id: probe.id } })).city === "Lübeck", "Rollback: Fachänderung verworfen");
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback")) === 0, "Rollback: kein Audit-Eintrag für die verworfene Änderung");
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback-denied", "denied")) === 1, "Rollback: „denied\"-Eintrag bleibt (Sicherheitsereignis)");
|
||||||
|
|
||||||
|
await inTransaction(ctxAdminA, async (outer) => {
|
||||||
|
await inTransaction(outer, async (inner) => {
|
||||||
|
await writeAuditLog({ tenantId: inner.tenantId, action: "update", entity: "zz_l10b_tx", entityId: "nested" });
|
||||||
|
});
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: innere Transaktion schreibt nicht vorzeitig");
|
||||||
|
throw new Error("zz outer rollback");
|
||||||
|
}).catch(() => undefined);
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: äußerer Rollback verwirft auch innere Audit-Einträge");
|
||||||
|
await writeAuditLog({ tenantId: tenantA.id, action: "update", entity: "zz_l10b_tx", entityId: "direct" });
|
||||||
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "direct")) === 1, "außerhalb einer Transaktion: sofort geschrieben");
|
||||||
|
|
||||||
|
console.log("\n— d) mergeCustomers atomar —");
|
||||||
|
const src = await customer(tenantA.id, "Quelle GmbH");
|
||||||
|
const tgt = await customer(tenantA.id, "Ziel GmbH");
|
||||||
|
const srcSite = await site(tenantA.id, src.id);
|
||||||
|
await expectCode(() => mergeCustomers(ctxTechA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen");
|
||||||
|
await expectCode(() => mergeCustomers(ctxAdminB, { sourceId: src.id, targetId: tgt.id, confirm: true }), "not_found", "Mandant B kann Kunden von A nicht zusammenführen");
|
||||||
|
await inTransaction(ctxAdminA, async (tx) => {
|
||||||
|
await mergeCustomers(tx, { sourceId: src.id, targetId: tgt.id, confirm: true });
|
||||||
|
throw new Error("zz merge rollback");
|
||||||
|
}).catch(() => undefined);
|
||||||
|
ok((await prisma.customer.findUniqueOrThrow({ where: { id: src.id } })).status !== "merged", "Merge in äußerer Transaktion + Rollback → Quelle nicht zusammengeführt");
|
||||||
|
ok((await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === src.id, "… und Objekt nicht umgehängt");
|
||||||
|
ok((await auditCount(tenantA.id, "customer", src.id)) === 0, "… und keine Merge-Audit-Einträge");
|
||||||
|
const merged = await mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true });
|
||||||
|
ok(merged.moved.sites === 1 && (await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === tgt.id, "Merge: Objekt umgehängt");
|
||||||
|
const srcAfter = await prisma.customer.findUniqueOrThrow({ where: { id: src.id } });
|
||||||
|
ok(srcAfter.status === "merged" && srcAfter.mergedIntoId === tgt.id, "Merge: Quelle merged + mergedIntoId");
|
||||||
|
ok((await auditCount(tenantA.id, "customer", src.id, "update")) === 1 && (await auditCount(tenantA.id, "customer", tgt.id, "update")) === 1, "Merge: Audit für Quelle und Ziel nach Commit");
|
||||||
|
await expectCode(() => mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "conflict", "zweites Zusammenführen → conflict");
|
||||||
|
|
||||||
|
console.log("\n— e) Audit-Aktion „read\" —");
|
||||||
|
const hits = await searchCustomersForEmergency(ctxTechA, "Ziel");
|
||||||
|
ok(hits.some((h) => h.id === tgt.id), "Notdienst-Suche findet Kunden");
|
||||||
|
const searchAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "emergency_customer_search" }, orderBy: { createdAt: "desc" } });
|
||||||
|
ok(searchAudit?.action === "read" && searchAudit.actorId === techA.id, "Suchzugriff als Aktion „read\" protokolliert");
|
||||||
|
ok((await searchCustomersForEmergency(ctxFor(tenantB.id, adminB.id, "technician"), "Ziel")).length === 0, "Mandant B findet keine Kunden von A");
|
||||||
|
await expectCode(() => searchCustomersForEmergency(ctxFor(tenantA.id, adminA.id, "backoffice"), "Ziel"), "forbidden", "ohne emergency:create → forbidden");
|
||||||
|
|
||||||
|
console.log("\n— k) Aufbewahrung KI-Protokoll —");
|
||||||
|
const now = new Date();
|
||||||
|
const gen = (tenantId: string, createdAt: Date, tokens = 10, createdById: string | null = null) =>
|
||||||
|
prisma.aiGeneration.create({
|
||||||
|
data: { tenantId, kind: "report_draft", provider: "fake", model: "fake-1", input: { text: "Kunde ruft an" }, output: { workPerformed: "x" }, inputTokens: tokens, outputTokens: tokens, createdById, createdAt },
|
||||||
|
});
|
||||||
|
const oldA = await gen(tenantA.id, new Date(now.getTime() - 200 * DAY), 10, techA.id);
|
||||||
|
const newA = await gen(tenantA.id, new Date(now.getTime() - 10 * DAY), 10, techA.id);
|
||||||
|
const oldB = await gen(tenantB.id, new Date(now.getTime() - 200 * DAY), 10, adminB.id);
|
||||||
|
ok(aiGenerationRetentionDays() === 180, "Default-Aufbewahrung 180 Tage");
|
||||||
|
process.env.AI_GENERATION_RETENTION_DAYS = "30";
|
||||||
|
ok(aiGenerationRetentionDays() === 30, "AI_GENERATION_RETENTION_DAYS überschreibt den Default");
|
||||||
|
delete process.env.AI_GENERATION_RETENTION_DAYS;
|
||||||
|
ok(typeof PROCESSORS["ai-retention"] === "function", "Job ai-retention im Worker registriert");
|
||||||
|
|
||||||
|
const r1 = await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] });
|
||||||
|
const oldAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldA.id } });
|
||||||
|
ok(r1.pseudonymised === 1 && oldAAfter.input === null && oldAAfter.output === null && oldAAfter.createdById === null, "abgelaufener Eintrag: Inhalte gelöscht, Personenbezug entfernt");
|
||||||
|
ok(oldAAfter.inputTokens === 10 && oldAAfter.model === "fake-1" && oldAAfter.kind === "report_draft", "Metadaten (Tokens, Modell, Art) bleiben");
|
||||||
|
const newAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: newA.id } });
|
||||||
|
ok(newAAfter.input !== null && newAAfter.createdById === techA.id, "junger Eintrag unverändert");
|
||||||
|
ok((await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldB.id } })).input !== null, "Lauf für Mandant A lässt Mandant B unberührt");
|
||||||
|
ok((await auditCount(tenantA.id, "ai_generation_retention", undefined, "delete")) === 1, "Aufbewahrungslauf auditiert");
|
||||||
|
ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] })).pseudonymised === 0, "zweiter Lauf idempotent");
|
||||||
|
ok((await purgeExpiredAiGenerations({ now, days: 365, tenantIds: [tenantB.id] })).pseudonymised === 0, "längere Frist → nichts gelöscht");
|
||||||
|
ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantB.id] })).pseudonymised === 1, "Mandant B eigener Lauf");
|
||||||
|
|
||||||
|
console.log("\n— k) Monatliches Token-Kontingent —");
|
||||||
|
await prisma.aiGeneration.deleteMany({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } });
|
||||||
|
delete process.env.AI_MONTHLY_TOKEN_LIMIT;
|
||||||
|
await gen(tenantA.id, now, 600);
|
||||||
|
let budget = await getTokenBudget(ctxTechA, now);
|
||||||
|
ok(budget.limit === 0 && !budget.exceeded && budget.used === 1200, "ohne Limit: unbegrenzt, Verbrauch = Tokens ein+aus des Monats");
|
||||||
|
await expectCode(() => updateLotseSettings(ctxTechA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }), "forbidden", "Monteur darf das Kontingent nicht setzen");
|
||||||
|
await updateLotseSettings(ctxAdminA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 });
|
||||||
|
budget = await getTokenBudget(ctxTechA, now);
|
||||||
|
ok(budget.limit === 1000 && budget.source === "tenant" && budget.exceeded, "Mandanten-Limit 1000 bei 1200 Verbrauch → aufgebraucht");
|
||||||
|
await expectCode(() => assertTokenBudget(ctxTechA, now), "blocked", "assertTokenBudget → blocked");
|
||||||
|
try {
|
||||||
|
await assertTokenBudget(ctxTechA, now);
|
||||||
|
} catch (err) {
|
||||||
|
ok((err as { details?: { reason?: string } }).details?.reason === "budget_exceeded", "… mit reason budget_exceeded");
|
||||||
|
}
|
||||||
|
const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } });
|
||||||
|
ok((settingsAudit?.after as { monthlyTokenLimit?: number } | null)?.monthlyTokenLimit === 1000, "Limit-Änderung auditiert");
|
||||||
|
ok((await getLotseSettings(ctxAdminA)).budget.tenantLimit === 1000, "Einstellungsseite liefert Limit und Verbrauch");
|
||||||
|
ok(!(await getTokenBudget(ctxAdminB, now)).exceeded && (await getTokenBudget(ctxAdminB, now)).used === 0, "Mandant B: eigener Verbrauch, nicht betroffen");
|
||||||
|
ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === null, "Mandant B: Limit unverändert");
|
||||||
|
|
||||||
|
await gen(tenantB.id, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - DAY), 5000);
|
||||||
|
process.env.AI_MONTHLY_TOKEN_LIMIT = "50";
|
||||||
|
budget = await getTokenBudget(ctxAdminB, now);
|
||||||
|
ok(budget.limit === 50 && budget.source === "env" && budget.used === 0 && !budget.exceeded, "Env-Default greift; Verbrauch des Vormonats zählt nicht");
|
||||||
|
await gen(tenantB.id, now, 30);
|
||||||
|
ok((await getTokenBudget(ctxAdminB, now)).exceeded, "Env-Limit 50 bei 60 Tokens → aufgebraucht");
|
||||||
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 0 });
|
||||||
|
ok(!(await getTokenBudget(ctxAdminB, now)).exceeded, "Mandanten-Limit 0 = unbegrenzt überschreibt Env");
|
||||||
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral" });
|
||||||
|
ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === 0, "Speichern ohne Limit-Feld lässt das Limit unverändert");
|
||||||
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: null });
|
||||||
|
ok((await getTokenBudget(ctxAdminB, now)).source === "env", "null → wieder Plattform-Vorgabe");
|
||||||
|
delete process.env.AI_MONTHLY_TOKEN_LIMIT;
|
||||||
|
|
||||||
|
// guard: tenant db cannot read the other tenant's usage
|
||||||
|
const crossUsage = await dbForTenant(tenantB.id).aiGeneration.count({ where: { tenantId: tenantA.id } }).catch(() => 0);
|
||||||
|
ok(crossUsage === 0, "Mandanten-Client von B sieht keine KI-Nutzung von A");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
ok(false, `unerwarteter Fehler: ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await cleanup().catch((e) => console.error("cleanup failed", e));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Audit-/Transaktions-/Lotse-Betriebsprüfungen grün");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
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 { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
||||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||||
import { PageHead } from "@/components/mockup-ui";
|
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 }> }) {
|
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
|
||||||
const ctx = await readCtx();
|
const ctx = await readCtx();
|
||||||
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
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 statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") };
|
||||||
|
const nf = new Intl.NumberFormat(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 p-4 md:p-6">
|
<main className="flex-1 p-4 md:p-6">
|
||||||
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<Button type="submit" className="min-h-11">
|
||||||
{t("settings.save")}
|
{t("settings.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -199,7 +199,9 @@ export type PlausibilityHintCode =
|
|||||||
| "email_invalid"
|
| "email_invalid"
|
||||||
| "phone_invalid"
|
| "phone_invalid"
|
||||||
| "end_before_start"
|
| "end_before_start"
|
||||||
| "manual_entry";
|
| "manual_entry"
|
||||||
|
/** L10b: monthly AI token budget of the tenant used up → manual entry */
|
||||||
|
| "ai_budget_exceeded";
|
||||||
|
|
||||||
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
|
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const LOTSE_ERROR_CODES = [
|
|||||||
"pending",
|
"pending",
|
||||||
"conflict",
|
"conflict",
|
||||||
"invalid",
|
"invalid",
|
||||||
|
"budget_exceeded",
|
||||||
] as const;
|
] as const;
|
||||||
export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number];
|
export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export async function saveLotseSettings(fd: FormData): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
|
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
|
||||||
const ctx = await requireApiContext(null, "tenant:manage");
|
const ctx = await requireApiContext(null, "tenant:manage");
|
||||||
|
// L10b: empty = platform default (null); invalid numbers are rejected by the service schema
|
||||||
|
const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim();
|
||||||
await updateLotseSettings(ctx, {
|
await updateLotseSettings(ctx, {
|
||||||
enabled: fd.get("enabled") === "on",
|
enabled: fd.get("enabled") === "on",
|
||||||
addressForm: (["sie", "du"].includes(String(fd.get("addressForm"))) ? String(fd.get("addressForm")) : "neutral") as "sie" | "du" | "neutral",
|
addressForm: (["sie", "du"].includes(String(fd.get("addressForm"))) ? String(fd.get("addressForm")) : "neutral") as "sie" | "du" | "neutral",
|
||||||
|
monthlyTokenLimit: rawLimit === "" ? null : Number(rawLimit),
|
||||||
});
|
});
|
||||||
revalidatePath("/settings/lotse");
|
revalidatePath("/settings/lotse");
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
|||||||
transcription: () => import("./transcription").then((m) => m.process),
|
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
|
"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),
|
"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). */
|
/** Inline fallback when no Redis is available (dev/demo). */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const JOB_QUEUES = {
|
|||||||
transcription: "transcription",
|
transcription: "transcription",
|
||||||
reportPdf: "report-pdf",
|
reportPdf: "report-pdf",
|
||||||
imageDerivatives: "image-derivatives",
|
imageDerivatives: "image-derivatives",
|
||||||
|
aiRetention: "ai-retention",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
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;
|
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> {
|
export async function closeJobQueues(): Promise<void> {
|
||||||
await Promise.all([...queues.values()].map((q) => q.close()));
|
await Promise.all([...queues.values()].map((q) => q.close()));
|
||||||
queues.clear();
|
queues.clear();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { checkPlausibility } from "@/lib/imports/plausibility";
|
|||||||
// TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface).
|
// TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface).
|
||||||
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
|
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
|
||||||
import { findSiteCandidates } from "./site-candidates";
|
import { findSiteCandidates } from "./site-candidates";
|
||||||
|
import { getTokenBudget } from "@/server/services/lotse/budget";
|
||||||
|
|
||||||
export type ProcessDeps = {
|
export type ProcessDeps = {
|
||||||
provider: DocumentExtractionProvider | null;
|
provider: DocumentExtractionProvider | null;
|
||||||
@@ -45,9 +46,11 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro
|
|||||||
let providerName: string | null = null;
|
let providerName: string | null = null;
|
||||||
let model: string | null = null;
|
let model: string | null = null;
|
||||||
|
|
||||||
if (!deps.provider) {
|
// L10b: monthly AI token budget used up → same graceful path as without provider
|
||||||
|
const budgetExceeded = deps.provider ? (await getTokenBudget(ctx, deps.now)).exceeded : false;
|
||||||
|
if (!deps.provider || budgetExceeded) {
|
||||||
fields = emptyExtraction();
|
fields = emptyExtraction();
|
||||||
hints = [{ field: null, code: "manual_entry" }];
|
hints = budgetExceeded ? [{ field: null, code: "manual_entry" }, { field: null, code: "ai_budget_exceeded" }] : [{ field: null, code: "manual_entry" }];
|
||||||
} else {
|
} else {
|
||||||
const bytes = await deps.loadBytes({ storageKey: doc.storageKey });
|
const bytes = await deps.loadBytes({ storageKey: doc.storageKey });
|
||||||
if (!bytes) throw new Error("file_unavailable");
|
if (!bytes) throw new Error("file_unavailable");
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly AI token budget per tenant (Spec §31 „Kostenlimit", L10b).
|
||||||
|
* Limit = `TenantSettings.aiMonthlyTokenLimit` if set, otherwise env `AI_MONTHLY_TOKEN_LIMIT`
|
||||||
|
* (default 0). 0 = unlimited. Usage = sum of input + output tokens of all `AiGeneration`s of the
|
||||||
|
* tenant since the start of the current calendar month (UTC). Checked BEFORE a provider call —
|
||||||
|
* one call may overshoot the limit by its own size, the next one is refused.
|
||||||
|
* Transcription (audio) reports no tokens and is not counted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function envMonthlyTokenLimit(): number {
|
||||||
|
const v = Number(process.env.AI_MONTHLY_TOKEN_LIMIT);
|
||||||
|
return Number.isInteger(v) && v >= 0 ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenBudget = {
|
||||||
|
/** effective limit, 0 = unlimited */
|
||||||
|
limit: number;
|
||||||
|
source: "tenant" | "env";
|
||||||
|
/** tenant override as stored (null = platform default) */
|
||||||
|
tenantLimit: number | null;
|
||||||
|
used: number;
|
||||||
|
periodStart: string;
|
||||||
|
exceeded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function monthStart(now: Date): Date {
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTokenBudget(ctx: Pick<ServiceCtx, "db">, now: Date = new Date()): Promise<TokenBudget> {
|
||||||
|
const start = monthStart(now);
|
||||||
|
const [settings, usage] = await Promise.all([
|
||||||
|
ctx.db.tenantSettings.findFirst({ select: { aiMonthlyTokenLimit: true } }),
|
||||||
|
ctx.db.aiGeneration.aggregate({ where: { createdAt: { gte: start } }, _sum: { inputTokens: true, outputTokens: true } }),
|
||||||
|
]);
|
||||||
|
const tenantLimit = settings?.aiMonthlyTokenLimit ?? null;
|
||||||
|
const limit = tenantLimit ?? envMonthlyTokenLimit();
|
||||||
|
const used = (usage._sum.inputTokens ?? 0) + (usage._sum.outputTokens ?? 0);
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
source: tenantLimit === null ? "env" : "tenant",
|
||||||
|
tenantLimit,
|
||||||
|
used,
|
||||||
|
periodStart: start.toISOString(),
|
||||||
|
exceeded: limit > 0 && used >= limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws `blocked` (details.reason = "budget_exceeded") when the monthly budget is used up. */
|
||||||
|
export async function assertTokenBudget(ctx: Pick<ServiceCtx, "db">, now: Date = new Date()): Promise<void> {
|
||||||
|
const budget = await getTokenBudget(ctx, now);
|
||||||
|
if (budget.exceeded) {
|
||||||
|
throw new ServiceError("blocked", "monthly AI token budget exceeded", { reason: "budget_exceeded", limit: budget.limit, used: budget.used });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { contentOf, requireVisibleReport } from "@/server/services/reports/commo
|
|||||||
import { buildReportDraftInput } from "./minimize";
|
import { buildReportDraftInput } from "./minimize";
|
||||||
import { assertLotseEnabled, lotseVoice } from "./settings";
|
import { assertLotseEnabled, lotseVoice } from "./settings";
|
||||||
import { loadDraftNotes, loadMinimizationContext } from "./sources";
|
import { loadDraftNotes, loadMinimizationContext } from "./sources";
|
||||||
|
import { assertTokenBudget } from "./budget";
|
||||||
|
|
||||||
export type LotseDeps = { provider: LotseAssistant | null; now?: () => Date };
|
export type LotseDeps = { provider: LotseAssistant | null; now?: () => Date };
|
||||||
export const defaultLotseDeps = (): LotseDeps => ({ provider: getLotseProvider() });
|
export const defaultLotseDeps = (): LotseDeps => ({ provider: getLotseProvider() });
|
||||||
@@ -45,6 +46,7 @@ export async function prepareDraftInput(ctx: ServiceCtx, report: Report, content
|
|||||||
export async function draftReportWithLotse(ctx: ServiceCtx, reportId: string, deps: LotseDeps = defaultLotseDeps()) {
|
export async function draftReportWithLotse(ctx: ServiceCtx, reportId: string, deps: LotseDeps = defaultLotseDeps()) {
|
||||||
const report = await requireDraftableReport(ctx, reportId);
|
const report = await requireDraftableReport(ctx, reportId);
|
||||||
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
||||||
|
await assertTokenBudget(ctx); // L10b: monthly token budget (blocked, reason budget_exceeded)
|
||||||
const content = contentOf(report);
|
const content = contentOf(report);
|
||||||
const input = await prepareDraftInput(ctx, report, content);
|
const input = await prepareDraftInput(ctx, report, content);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention of the AI log (Spec §31 „Aufbewahrung", L9 offener Punkt 4, L10b):
|
||||||
|
* after `AI_GENERATION_RETENTION_DAYS` (default 180) the CONTENT of an AiGeneration (minimised
|
||||||
|
* input sent to the provider, provider output) is deleted and the person reference is removed
|
||||||
|
* (`createdById = null`). Metadata (kind, provider, model, tokens, entity reference, time) stays
|
||||||
|
* for cost statistics and the reference from reports (`aiGenerationId`) keeps resolving.
|
||||||
|
* Runs daily in the craftvia worker (queue `ai-retention`); idempotent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_AI_GENERATION_RETENTION_DAYS = 180;
|
||||||
|
|
||||||
|
export function aiGenerationRetentionDays(): number {
|
||||||
|
const v = Number(process.env.AI_GENERATION_RETENTION_DAYS);
|
||||||
|
return Number.isInteger(v) && v > 0 ? v : DEFAULT_AI_GENERATION_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RetentionResult = { cutoff: string; days: number; tenants: number; pseudonymised: number };
|
||||||
|
|
||||||
|
export async function purgeExpiredAiGenerations(opts: { now?: Date; days?: number; tenantIds?: string[] } = {}): Promise<RetentionResult> {
|
||||||
|
const days = opts.days ?? aiGenerationRetentionDays();
|
||||||
|
const cutoff = new Date((opts.now ?? new Date()).getTime() - days * 24 * 60 * 60 * 1000);
|
||||||
|
// tenant list = platform data (no tenant content); every content change runs through dbForTenant
|
||||||
|
const tenantIds = opts.tenantIds ?? (await prisma.tenant.findMany({ select: { id: true } })).map((t) => t.id);
|
||||||
|
|
||||||
|
let pseudonymised = 0;
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
const db = dbForTenant(tenantId);
|
||||||
|
const res = await db.aiGeneration.updateMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: cutoff },
|
||||||
|
OR: [{ input: { not: Prisma.DbNull } }, { output: { not: Prisma.DbNull } }, { createdById: { not: null } }],
|
||||||
|
},
|
||||||
|
data: { input: Prisma.DbNull, output: Prisma.DbNull, createdById: null },
|
||||||
|
});
|
||||||
|
if (res.count > 0) {
|
||||||
|
pseudonymised += res.count;
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId,
|
||||||
|
action: "delete",
|
||||||
|
entity: "ai_generation_retention",
|
||||||
|
after: { count: res.count, cutoff: cutoff.toISOString(), retentionDays: days },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cutoff: cutoff.toISOString(), days, tenants: tenantIds.length, pseudonymised };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
|
|||||||
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
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),
|
* 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) {
|
export async function getLotseSettings(ctx: ServiceCtx) {
|
||||||
assertCan(ctx, "tenant:manage");
|
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();
|
const transcription = transcriptionConfig();
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
addressForm: toAddressForm(s?.lotseAddressForm),
|
addressForm: toAddressForm(s?.lotseAddressForm),
|
||||||
draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL },
|
draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL },
|
||||||
transcription,
|
transcription,
|
||||||
|
budget: { ...budget, platformLimit: envMonthlyTokenLimit() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lotseSettingsSchema = z.object({
|
export const lotseSettingsSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
addressForm: z.enum(["sie", "du", "neutral"]),
|
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>;
|
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 input = lotseSettingsSchema.parse(raw);
|
||||||
const addressForm = input.addressForm === "neutral" ? null : input.addressForm;
|
const addressForm = input.addressForm === "neutral" ? null : input.addressForm;
|
||||||
|
|
||||||
|
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true } });
|
||||||
const before = {
|
const before = {
|
||||||
enabled: await isLotseEnabled(ctx),
|
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 inTransaction(ctx, async (tx) => {
|
||||||
await tx.db.tenantModule.upsert({
|
await tx.db.tenantModule.upsert({
|
||||||
where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
|
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 },
|
create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled },
|
||||||
});
|
});
|
||||||
const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } });
|
const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } });
|
||||||
|
const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit };
|
||||||
if (existing) {
|
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 {
|
} else {
|
||||||
const tenant = await tx.db.tenant.findUnique({ where: { id: tx.tenantId }, select: { name: true } });
|
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 });
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after });
|
||||||
return after;
|
return after;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createNote } from "@/server/services/field/notes";
|
|||||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
import { defaultLotseDeps, type LotseDeps } from "./draft-report";
|
import { defaultLotseDeps, type LotseDeps } from "./draft-report";
|
||||||
import { scrubText } from "./minimize";
|
import { scrubText } from "./minimize";
|
||||||
|
import { assertTokenBudget } from "./budget";
|
||||||
import { assertLotseEnabled, lotseVoice } from "./settings";
|
import { assertLotseEnabled, lotseVoice } from "./settings";
|
||||||
import { loadMinimizationContext } from "./sources";
|
import { loadMinimizationContext } from "./sources";
|
||||||
import { TRANSCRIPT_SEPARATOR } from "./transcription";
|
import { TRANSCRIPT_SEPARATOR } from "./transcription";
|
||||||
@@ -67,6 +68,7 @@ export async function summarizeVoiceNote(ctx: ServiceCtx, voiceNoteId: string, d
|
|||||||
await assertLotseEnabled(ctx);
|
await assertLotseEnabled(ctx);
|
||||||
if (!voice.transcript?.trim()) throw new ServiceError("invalid", "voice note has no transcript", { reason: "no_transcript" });
|
if (!voice.transcript?.trim()) throw new ServiceError("invalid", "voice note has no transcript", { reason: "no_transcript" });
|
||||||
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
||||||
|
await assertTokenBudget(ctx); // L10b: monthly token budget
|
||||||
|
|
||||||
const [minimization, lang] = await Promise.all([loadMinimizationContext(ctx, voice.workOrderId), lotseVoice(ctx)]);
|
const [minimization, lang] = await Promise.all([loadMinimizationContext(ctx, voice.workOrderId), lotseVoice(ctx)]);
|
||||||
const transcript = scrubText(voice.transcript, minimization);
|
const transcript = scrubText(voice.transcript, minimization);
|
||||||
|
|||||||
Reference in New Issue
Block a user