feat: define conversational rectification contract

This commit is contained in:
Jesse_Chen
2026-07-20 15:44:37 +08:00
parent ec6ec7ded6
commit 18234e7e60
3 changed files with 297 additions and 0 deletions
@@ -0,0 +1,92 @@
import { z } from "zod";
const actionIdSchema = z.string().uuid();
const caseIdSchema = z.string().uuid();
const turnVersionSchema = z.number().int().nonnegative();
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const evidenceDomainSchema = z.enum([
"career",
"education",
"relocation",
"relationship",
"family",
"other",
]);
const actionCommandSchema = z.object({
caseId: caseIdSchema,
actionId: actionIdSchema,
turnVersion: turnVersionSchema,
});
/**
* The only browser-to-server commands for conversational-evidence-v3.
* Calculation inputs and technical receipts are server-owned and deliberately absent.
*/
export const conversationalRectificationCommandSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("start"),
actionId: actionIdSchema,
pendingConsultationQuestion: z.string().trim().min(1).max(500).nullable().optional(),
}).strict(),
actionCommandSchema.extend({
type: z.literal("resume"),
}).strict(),
actionCommandSchema.extend({
type: z.literal("answer"),
domain: evidenceDomainSchema.optional(),
answer: z.string().trim().min(1).max(4_000),
}).strict(),
actionCommandSchema.extend({
type: z.literal("pause"),
}).strict(),
actionCommandSchema.extend({
type: z.literal("abandon"),
}).strict(),
actionCommandSchema.extend({
type: z.literal("confirm"),
time: timeSchema,
}).strict(),
]);
export type ConversationalRectificationCommand = z.infer<typeof conversationalRectificationCommandSchema>;
export const conversationalRectificationTurnSchema = z.object({
caseId: caseIdSchema,
journeyProtocol: z.literal("conversational-evidence-v3"),
status: z.enum(["active", "paused", "confirming", "completed", "abandoned"]),
turnVersion: turnVersionSchema,
narrative: z.string().trim().min(1).max(12_000),
candidate: z.object({
status: z.enum(["declared", "pending_validation", "ready_for_confirmation", "confirmed"]),
representativeTime: timeSchema.nullable(),
rangeStart: timeSchema.nullable(),
rangeEnd: timeSchema.nullable(),
}).strict(),
technicalReceipt: z.object({
calculationVersion: z.string().trim().min(1).max(80),
stableLayers: z.array(z.string().trim().min(1).max(80)).max(20),
sensitiveLayers: z.array(z.string().trim().min(1).max(80)).max(20),
candidateDifferenceRefs: z.array(z.string().trim().min(1).max(120)).max(40),
}).strict(),
evidenceRequest: z.object({
domains: z.array(evidenceDomainSchema).min(2).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
freeTextAllowed: z.literal(true),
}).strict().nullable(),
evidenceRecap: z.array(z.object({
id: z.string().uuid(),
summary: z.string(),
dateLabel: z.string(),
}).strict()).max(20),
actions: z.array(z.enum([
"answer",
"pause",
"abandon",
"confirm",
"continue_original_question",
])).max(5),
pendingConsultationQuestion: z.string().max(500).nullable(),
}).strict();
export type ConversationalRectificationTurn = z.infer<typeof conversationalRectificationTurnSchema>;
@@ -0,0 +1,89 @@
const errorDefinitions = {
invalid_command: {
status: 400,
error: "校正请求格式不正确",
message: "请检查填写内容后再试。",
},
authentication_required: {
status: 401,
error: "请先登录",
message: "登录后才能继续生时校正。",
},
case_not_found: {
status: 404,
error: "校正记录不存在",
message: "请重新开始生时校正。",
},
stale_turn: {
status: 409,
error: "校正进度已更新",
message: "请加载最新进度后再试。",
},
invalid_transition: {
status: 409,
error: "当前步骤不可用",
message: "请加载最新进度后再试。",
},
candidate_changed: {
status: 409,
error: "候选结果已变化",
message: "请查看最新候选结果后再确认。",
},
profile_incomplete: {
status: 409,
error: "出生资料尚未完成",
message: "请先补全出生日期、时间和地点。",
},
insufficient_credits: {
status: 409,
error: "校正点数不足",
message: "请补充点数后再开始校正。",
},
service_unavailable: {
status: 503,
error: "生时校正暂时不可用",
message: "当前资料已安全保留,请稍后重试。",
},
} as const;
export type ConversationalRectificationErrorCode = keyof typeof errorDefinitions;
export type ConversationalRectificationPublicError = Readonly<{
code: ConversationalRectificationErrorCode;
status: number;
error: string;
message: string;
}>;
/**
* A domain error with a deliberately fixed public representation. The optional cause
* is retained only for server-side logging and is never copied into the response.
*/
export class ConversationalRectificationError extends Error {
readonly name = "ConversationalRectificationError";
readonly code: ConversationalRectificationErrorCode;
readonly status: number;
readonly public: ConversationalRectificationPublicError;
constructor(code: ConversationalRectificationErrorCode, options?: ErrorOptions) {
const definition = errorDefinitions[code];
super(definition.error, options);
this.code = code;
this.status = definition.status;
this.public = {
code,
status: definition.status,
error: definition.error,
message: definition.message,
};
}
}
/**
* Converts unknown database, browser, and model failures to one safe recovery error.
*/
export function toConversationalRectificationError(error: unknown): ConversationalRectificationError {
return error instanceof ConversationalRectificationError
? error
: new ConversationalRectificationError("service_unavailable", { cause: error });
}