L9 Lotse – KI-Assistent: Transkription, Berichtsentwurf, Vollständigkeitsprüfung, Freigabeprinzip (Services)

- OpenAI-kompatible Transkription + Processor transcription (done/failed/disabled, AiGeneration, Notiz aus Sprachnotiz)
- Claude-Lotse (strukturierte Ausgabe, Refusal/Fallback), Datenminimierung, Vorschläge in content.lotse
- Vollständigkeitsprüfung (Regeln + KI-Hinweise mit Deep-Link), Einstellungen, KI-Protokoll
- Freigabeprinzip: Submit eines Lotse-Entwurfs nur mit Prüfbestätigung (serverseitig)
- Migration lotse_address_form (TenantSettings)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:19:38 +02:00
co-authored by Claude Opus 5
parent d5c1221ab5
commit ff5c57f276
39 changed files with 1815 additions and 7 deletions
+83
View File
@@ -0,0 +1,83 @@
import { z } from "zod";
import { LOTSE_ADDRESS_FORMS, type LotseAddressForm } from "@/lib/lotse/content";
import type { ReportDraftInput } from "@/server/ai/providers";
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";
/**
* Lotse settings per tenant (lane L9): on/off = module toggle `lotse` (TenantModule, missing row = on),
* address form = `TenantSettings.lotseAddressForm` ("sie" | "du" | null = neutral).
*/
export const LOTSE_MODULE_KEY = "lotse";
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
const row = await ctx.db.tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
select: { enabled: true },
});
return !row || row.enabled;
}
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
}
function toAddressForm(v: string | null | undefined): LotseAddressForm | null {
return (LOTSE_ADDRESS_FORMS as readonly string[]).includes(v ?? "") ? (v as LotseAddressForm) : null;
}
/** Address form and language for prompts. */
export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ addressForm: ReportDraftInput["addressForm"]; locale: ReportDraftInput["locale"] }> {
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, locale: true } });
return { addressForm: toAddressForm(s?.lotseAddressForm) ?? "neutral", locale: s?.locale === "en" ? "en" : "de" };
}
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 transcription = transcriptionConfig();
return {
enabled,
addressForm: toAddressForm(s?.lotseAddressForm),
draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL },
transcription,
};
}
export const lotseSettingsSchema = z.object({
enabled: z.boolean(),
addressForm: z.enum(["sie", "du", "neutral"]),
});
export type LotseSettingsInput = z.input<typeof lotseSettingsSchema>;
export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInput) {
assertCan(ctx, "tenant:manage");
const input = lotseSettingsSchema.parse(raw);
const addressForm = input.addressForm === "neutral" ? null : input.addressForm;
const before = {
enabled: await isLotseEnabled(ctx),
addressForm: (await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }))?.lotseAddressForm ?? null,
};
await inTransaction(ctx, async (tx) => {
await tx.db.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
update: { enabled: input.enabled },
create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled },
});
const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } });
if (existing) {
await tx.db.tenantSettings.update({ where: { id: existing.id }, data: { lotseAddressForm: addressForm } });
} 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 } });
}
});
const after = { enabled: input.enabled, addressForm };
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after });
return after;
}