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
+23
View File
@@ -0,0 +1,23 @@
/** Result of Lotse server actions (client-safe; "use server" files may only export async functions). */
export const LOTSE_ERROR_CODES = [
"generic",
"not_found",
"forbidden",
"disabled",
"not_configured",
"provider_failed",
"not_editable",
"no_suggestion",
"no_transcript",
"pending",
"conflict",
"invalid",
] as const;
export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number];
export type LotseActionState =
| { status: "idle" }
| { status: "ok"; at: number; count?: number; summary?: string; generationId?: string }
| { status: "error"; code: LotseActionErrorCode; at: number };
export const LOTSE_IDLE: LotseActionState = { status: "idle" };
+26
View File
@@ -0,0 +1,26 @@
/**
* Result items of the Lotse completeness check (client-safe, lane L9). Labels come from messages
* `lotse.completeness.item.<code>`; `text` is the model's plain-language hint for `lotse_hint`.
*/
export const COMPLETENESS_CODES = [
"photo_requirement",
"checklist_item",
"material_reason",
"material_unconfirmed",
"no_work_time",
"no_description",
"signature_missing",
"lotse_hint",
] as const;
export type CompletenessCode = (typeof COMPLETENESS_CODES)[number];
export type CompletenessItem = {
code: CompletenessCode;
/** checklist/photo requirement/material label */
label?: string;
/** Lotse hint text (missingInformation) */
text?: string;
/** deep link into the mobile sub page */
href: string;
source: "rule" | "lotse";
};
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
/**
* Lotse block of the report snapshot (client-safe, lane L9). Stored as optional `content.lotse` of
* `ReportContent` (src/lib/reports/content.ts), so it is frozen together with the report on approval.
*
* Suggestions are kept SEPARATE from `content.texts`: nothing the model wrote reaches the report text
* until a person accepts it (Spec §15.3/§15.4, Brandbook §12.4). `reviewedAt` is set on submit when
* the submitter confirmed "Ich habe den Vorschlag geprüft" (checked server-side in submitReport).
*/
/** Report text fields the Lotse drafts (ReportDraftOutput); `problems` stays technician-only. */
export const LOTSE_TEXT_FIELDS = ["workPerformed", "deviations", "additionalWork", "openItems", "nextSteps", "hints"] as const;
export type LotseTextField = (typeof LOTSE_TEXT_FIELDS)[number];
export const LOTSE_SUGGESTION_STATES = ["pending", "accepted", "discarded"] as const;
export type LotseSuggestionState = (typeof LOTSE_SUGGESTION_STATES)[number];
export const LOTSE_TEXT_MAX = 10_000;
export const LOTSE_MISSING_MAX = 20;
const isoDateTime = z.string().datetime({ offset: true });
export const lotseSuggestionSchema = z.object({
field: z.enum(LOTSE_TEXT_FIELDS),
text: z.string().max(LOTSE_TEXT_MAX),
state: z.enum(LOTSE_SUGGESTION_STATES),
decidedAt: isoDateTime.nullable(),
});
export type LotseSuggestion = z.infer<typeof lotseSuggestionSchema>;
export const lotseBlockSchema = z.object({
generationId: z.string(),
model: z.string(),
draftedAt: isoDateTime,
draftedById: z.string().nullable(),
suggestions: z.array(lotseSuggestionSchema),
/** plain-language hints of what the model could not find in the data */
missingInformation: z.array(z.string().max(500)).max(LOTSE_MISSING_MAX),
reviewedAt: isoDateTime.nullable(),
reviewedById: z.string().nullable(),
});
export type LotseBlock = z.infer<typeof lotseBlockSchema>;
export const LOTSE_ADDRESS_FORMS = ["sie", "du"] as const;
export type LotseAddressForm = (typeof LOTSE_ADDRESS_FORMS)[number];
export function pendingSuggestions(block: LotseBlock | null | undefined): LotseSuggestion[] {
return block ? block.suggestions.filter((s) => s.state === "pending") : [];
}
+3
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { lotseBlockSchema } from "@/lib/lotse/content";
/**
* Report content snapshot (ARCHITEKTUR §4.7, Spec §16.2/§17.2). Client-safe.
@@ -170,6 +171,8 @@ export const reportContentSchema = z.object({
checklist: z.array(z.object({ label: z.string(), required: z.boolean(), checked: z.boolean(), comment: nullableText })),
signature: signatureBlockSchema.nullable(),
technician: z.object({ userId: z.string(), name: z.string() }).nullable(),
/** Lotse suggestions + review proof (lane L9, optional — older snapshots have none) */
lotse: lotseBlockSchema.optional(),
});
export type ReportContent = z.infer<typeof reportContentSchema>;