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:
@@ -0,0 +1,186 @@
|
||||
import { z } from "zod";
|
||||
import { NOTE_KINDS, TIME_TYPES } from "@/lib/sync/ops";
|
||||
import { LOTSE_TEXT_FIELDS, LOTSE_TEXT_MAX } from "@/lib/lotse/content";
|
||||
|
||||
/**
|
||||
* Lotse chat for technicians (lane L16) — client-safe contract: proposal kinds and their payload
|
||||
* schemas, structured message parts, action state of the chat server actions.
|
||||
*
|
||||
* Principle: the Lotse never executes anything. Proposal tools create a `LotseActionProposal`
|
||||
* whose payload is validated with these schemas; only the owner's confirmation runs the existing
|
||||
* services (services/lotse/chat/confirm.ts).
|
||||
*/
|
||||
|
||||
export const PROPOSAL_TTL_MINUTES = 30;
|
||||
export const CHAT_TEXT_MAX = 4_000;
|
||||
|
||||
export const PROPOSAL_KINDS = ["transition_work_order", "book_time", "record_material", "add_note", "suggest_report_fields"] as const;
|
||||
export type ProposalKind = (typeof PROPOSAL_KINDS)[number];
|
||||
|
||||
/** Execution order of „Alle bestätigen": bookings first, the status change (completion) last. */
|
||||
export const PROPOSAL_ORDER: Record<ProposalKind, number> = {
|
||||
book_time: 1,
|
||||
record_material: 2,
|
||||
add_note: 3,
|
||||
suggest_report_fields: 4,
|
||||
transition_work_order: 5,
|
||||
};
|
||||
|
||||
export const TRANSITION_ACTIONS = ["accept", "start_travel", "start_work", "pause", "resume", "complete"] as const;
|
||||
export type TransitionAction = (typeof TRANSITION_ACTIONS)[number];
|
||||
|
||||
/** Time types the chat may book (breaks are not booked via chat). */
|
||||
export const CHAT_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement"] as const satisfies readonly (typeof TIME_TYPES)[number][];
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const number = z.string().min(1).max(64);
|
||||
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const clock = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
|
||||
export const transitionPayloadSchema = z.object({
|
||||
workOrderId: id,
|
||||
number,
|
||||
action: z.enum(TRANSITION_ACTIONS),
|
||||
/** L12 auto switch: number of the order whose running clock is paused on confirmation */
|
||||
switchFrom: number.nullish(),
|
||||
});
|
||||
|
||||
export const bookTimePayloadSchema = z
|
||||
.object({
|
||||
workOrderId: id,
|
||||
number,
|
||||
type: z.enum(CHAT_TIME_TYPES),
|
||||
date: dateKey,
|
||||
/** local time HH:MM (tenant time zone) — either from/to or durationMinutes */
|
||||
from: clock.nullish(),
|
||||
to: clock.nullish(),
|
||||
durationMinutes: z.number().int().min(1).max(16 * 60).nullish(),
|
||||
/** L12: mandatory reason of a manual entry */
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
note: z.string().trim().max(2000).nullish(),
|
||||
})
|
||||
.refine((v) => (v.from && v.to) || v.durationMinutes, { message: "from/to or durationMinutes required", path: ["durationMinutes"] });
|
||||
|
||||
export const recordMaterialPayloadSchema = z.object({
|
||||
workOrderId: id,
|
||||
number,
|
||||
materialPlanId: id.nullish(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
articleNumber: z.string().trim().max(100).nullish(),
|
||||
quantity: z.number().min(0).max(1_000_000),
|
||||
unit: z.string().trim().min(1).max(20),
|
||||
usageStatus: z.enum(["fully_used", "partially_used", "not_used", "additional"]),
|
||||
deviationReason: z.string().trim().max(2000).nullish(),
|
||||
});
|
||||
|
||||
export const addNotePayloadSchema = z.object({
|
||||
workOrderId: id,
|
||||
number,
|
||||
kind: z.enum(NOTE_KINDS),
|
||||
text: z.string().trim().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export const reportFieldsPayloadSchema = z.object({
|
||||
workOrderId: id,
|
||||
number,
|
||||
reportId: id,
|
||||
reportType: z.enum(["daily", "completion"]),
|
||||
fields: z
|
||||
.array(z.object({ field: z.enum(LOTSE_TEXT_FIELDS), text: z.string().trim().min(1).max(LOTSE_TEXT_MAX) }))
|
||||
.min(1)
|
||||
.max(LOTSE_TEXT_FIELDS.length),
|
||||
});
|
||||
|
||||
export const PROPOSAL_SCHEMAS = {
|
||||
transition_work_order: transitionPayloadSchema,
|
||||
book_time: bookTimePayloadSchema,
|
||||
record_material: recordMaterialPayloadSchema,
|
||||
add_note: addNotePayloadSchema,
|
||||
suggest_report_fields: reportFieldsPayloadSchema,
|
||||
} as const;
|
||||
|
||||
export type ProposalPayload<K extends ProposalKind> = z.output<(typeof PROPOSAL_SCHEMAS)[K]>;
|
||||
|
||||
/** Fields a technician may change before confirming („Bearbeiten"); ids/number stay fixed. */
|
||||
export const EDITABLE_FIELDS: Record<ProposalKind, readonly string[]> = {
|
||||
transition_work_order: [],
|
||||
book_time: ["type", "date", "from", "to", "durationMinutes", "reason", "note"],
|
||||
record_material: ["name", "quantity", "unit", "deviationReason"],
|
||||
add_note: ["kind", "text"],
|
||||
suggest_report_fields: ["fields"],
|
||||
};
|
||||
|
||||
export const PROPOSAL_STATUSES = ["proposed", "confirmed", "discarded", "expired", "failed"] as const;
|
||||
export type ProposalStatus = (typeof PROPOSAL_STATUSES)[number];
|
||||
|
||||
/** Chip = quick answer; `value` is sent as the next user message. */
|
||||
export type ChatChip = { label: string; value: string };
|
||||
/** Jump target (e.g. missing mandatory photo → /m/orders/<id>/photos). `labelKey` = messages lotse.chat.links.* */
|
||||
export type ChatLink = { labelKey: string; label?: string; href: string };
|
||||
|
||||
export type ChatMessageContent = {
|
||||
chips?: ChatChip[];
|
||||
proposalIds?: string[];
|
||||
links?: ChatLink[];
|
||||
/** assistant text exactly as produced by the model (with placeholders) — history for the next turn */
|
||||
modelText?: string;
|
||||
/** user text as sent to the model (minimised) */
|
||||
sentText?: string;
|
||||
/** i18n key for server-generated messages (lotse.chat.system.*) */
|
||||
noticeKey?: string;
|
||||
noticeValues?: Record<string, string | number>;
|
||||
/** rounds of the tool loop (transparency) */
|
||||
rounds?: number;
|
||||
};
|
||||
|
||||
export type ChatProposalView = {
|
||||
id: string;
|
||||
kind: ProposalKind;
|
||||
status: ProposalStatus;
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string;
|
||||
errorCode: string | null;
|
||||
result: Record<string, unknown> | null;
|
||||
workOrderId: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessageView = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "tool";
|
||||
text: string;
|
||||
createdAt: string;
|
||||
content: ChatMessageContent;
|
||||
proposals: ChatProposalView[];
|
||||
};
|
||||
|
||||
export type ChatView = {
|
||||
conversationId: string | null;
|
||||
workOrder: { id: string; number: string; title: string } | null;
|
||||
messages: ChatMessageView[];
|
||||
};
|
||||
|
||||
export const LOTSE_CHAT_ERROR_CODES = [
|
||||
"generic",
|
||||
"not_found",
|
||||
"forbidden",
|
||||
"disabled",
|
||||
"chat_disabled",
|
||||
"not_configured",
|
||||
"provider_failed",
|
||||
"budget_exceeded",
|
||||
"invalid",
|
||||
"conflict",
|
||||
"expired",
|
||||
"already_decided",
|
||||
"tampered",
|
||||
"blocked",
|
||||
"offline",
|
||||
] as const;
|
||||
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
|
||||
|
||||
export type LotseChatActionState =
|
||||
| { status: "idle" }
|
||||
| { status: "ok"; at: number; view: ChatView }
|
||||
| { status: "error"; code: LotseChatErrorCode; at: number; view?: ChatView };
|
||||
|
||||
export const LOTSE_CHAT_IDLE: LotseChatActionState = { status: "idle" };
|
||||
Reference in New Issue
Block a user