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>
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
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" };
|