L16 Lotse-Chat für Monteure: Datenmodell, Provider mit Tool-Schleife, Vorschläge und Bestätigen über bestehende Services

Chatverlauf (LotseConversation/-Message) und Aktionskarten (LotseActionProposal) als
Mandantentabellen mit RLS; Schalter lotseChatEnabled. Tool-Use-Schleife mit Runden- und
Tokengrenze, Datenminimierung per Platzhalter, Auftragszuordnung im Sichtbarkeits-Scope,
Bestätigen/Verwerfen/Alle bestätigen mit Hash, Ablauf und Idempotenz, Transkriptions-API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:57:20 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit 68f4eb32dc
26 changed files with 2363 additions and 6 deletions
+10 -4
View File
@@ -41,13 +41,15 @@ export async function getLotseSettings(ctx: ServiceCtx) {
assertCan(ctx, "tenant:manage");
const [enabled, s, budget] = await Promise.all([
isLotseEnabled(ctx),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
getTokenBudget(ctx),
]);
const transcription = transcriptionConfig();
return {
enabled,
addressForm: toAddressForm(s?.lotseAddressForm),
/** L16: „Lotse-Chat für Monteure" (default on) */
chatEnabled: s?.lotseChatEnabled ?? true,
draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL },
transcription,
budget: { ...budget, platformLimit: envMonthlyTokenLimit() },
@@ -59,6 +61,8 @@ export const lotseSettingsSchema = z.object({
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(),
/** L16: Lotse chat for technicians; undefined = unchanged */
chatEnabled: z.boolean().optional(),
});
export type LotseSettingsInput = z.input<typeof lotseSettingsSchema>;
@@ -67,13 +71,15 @@ 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 stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
const before = {
enabled: await isLotseEnabled(ctx),
addressForm: stored?.lotseAddressForm ?? null,
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
chatEnabled: stored?.lotseChatEnabled ?? true,
};
const monthlyTokenLimit = input.monthlyTokenLimit === undefined ? before.monthlyTokenLimit : input.monthlyTokenLimit;
const chatEnabled = input.chatEnabled === undefined ? before.chatEnabled : input.chatEnabled;
await inTransaction(ctx, async (tx) => {
await tx.db.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
@@ -81,7 +87,7 @@ 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 };
const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit, lotseChatEnabled: chatEnabled };
if (existing) {
await tx.db.tenantSettings.update({ where: { id: existing.id }, data });
} else {
@@ -89,7 +95,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", ...data } });
}
});
const after = { enabled: input.enabled, addressForm, monthlyTokenLimit };
const after = { enabled: input.enabled, addressForm, monthlyTokenLimit, chatEnabled };
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after });
return after;
}