From 18234e7e6010aa0c2b914d5d38cb2980d7243c5c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 15:44:37 +0800 Subject: [PATCH] feat: define conversational rectification contract --- .../conversational-rectification/contracts.ts | 92 ++++++++++++++ .../conversational-rectification/errors.ts | 89 ++++++++++++++ ...ersational-rectification-contracts.test.ts | 116 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 frontend/src/lib/conversational-rectification/contracts.ts create mode 100644 frontend/src/lib/conversational-rectification/errors.ts create mode 100644 frontend/tests/conversational-rectification-contracts.test.ts diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts new file mode 100644 index 00000000..38080e76 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -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; + +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; diff --git a/frontend/src/lib/conversational-rectification/errors.ts b/frontend/src/lib/conversational-rectification/errors.ts new file mode 100644 index 00000000..c0aa89fb --- /dev/null +++ b/frontend/src/lib/conversational-rectification/errors.ts @@ -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 }); +} diff --git a/frontend/tests/conversational-rectification-contracts.test.ts b/frontend/tests/conversational-rectification-contracts.test.ts new file mode 100644 index 00000000..670e2435 --- /dev/null +++ b/frontend/tests/conversational-rectification-contracts.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + conversationalRectificationCommandSchema, + conversationalRectificationTurnSchema, +} from "../src/lib/conversational-rectification/contracts.ts"; +import { + ConversationalRectificationError, + toConversationalRectificationError, +} from "../src/lib/conversational-rectification/errors.ts"; + +const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1"; +const caseId = "77b29d28-c576-429e-9e3d-d0a90348e3cb"; + +function parseCommand(value: unknown) { + return conversationalRectificationCommandSchema.safeParse(value).success; +} + +test("accepts only the six strict conversational commands", () => { + const commands = [ + { type: "start", actionId, pendingConsultationQuestion: null }, + { type: "resume", caseId, actionId, turnVersion: 0 }, + { type: "answer", caseId, actionId, turnVersion: 1, answer: "2019 年 7 月换了工作" }, + { type: "pause", caseId, actionId, turnVersion: 1 }, + { type: "abandon", caseId, actionId, turnVersion: 1 }, + { type: "confirm", caseId, actionId, turnVersion: 1, time: "05:21" }, + ]; + + for (const command of commands) assert.equal(parseCommand(command), true, command.type); + assert.equal(parseCommand({ type: "complete", caseId, actionId, turnVersion: 1 }), false); + assert.equal(parseCommand({ type: "pause", caseId, actionId, turnVersion: 1, ignored: true }), false); +}); + +test("requires UUID actions and current nonnegative versions after start", () => { + assert.equal(parseCommand({ type: "start", actionId: "not-a-uuid", pendingConsultationQuestion: null }), false); + assert.equal(parseCommand({ type: "resume", caseId, actionId: "not-a-uuid", turnVersion: 0 }), false); + assert.equal(parseCommand({ type: "answer", caseId, actionId, answer: "有效回答" }), false); + assert.equal(parseCommand({ type: "answer", caseId, actionId, turnVersion: -1, answer: "有效回答" }), false); + assert.equal(parseCommand({ type: "answer", caseId, actionId, turnVersion: 1.5, answer: "有效回答" }), false); +}); + +test("bounds free-text answers and requires a strict HH:mm confirmation time", () => { + assert.equal(parseCommand({ type: "answer", caseId, actionId, turnVersion: 1, answer: " " }), false); + assert.equal(parseCommand({ type: "answer", caseId, actionId, turnVersion: 1, answer: "x".repeat(4_001) }), false); + assert.equal(parseCommand({ type: "confirm", caseId, actionId, turnVersion: 1, time: "5:21" }), false); + assert.equal(parseCommand({ type: "confirm", caseId, actionId, turnVersion: 1, time: "24:00" }), false); +}); + +test("rejects client candidate scores and technical receipts", () => { + assert.equal(parseCommand({ + type: "answer", caseId, actionId, turnVersion: 1, answer: "2019 年 7 月换了工作", + candidateScores: [0.99], + }), false); + assert.equal(parseCommand({ + type: "confirm", caseId, actionId, turnVersion: 1, time: "05:21", + technicalReceipt: { calculationVersion: "client-forged" }, + }), false); +}); + +test("accepts only the exact public turn shape", () => { + const turn = { + caseId, + journeyProtocol: "conversational-evidence-v3", + status: "active", + turnVersion: 1, + narrative: "我们先用已经发生的人生事件缩小候选范围。", + candidate: { + status: "pending_validation", + representativeTime: "05:21", + rangeStart: "05:10", + rangeEnd: "05:30", + }, + technicalReceipt: { + calculationVersion: "v3.0", + stableLayers: ["D1"], + sensitiveLayers: ["D9"], + candidateDifferenceRefs: ["candidate-difference-1"], + }, + evidenceRequest: { + domains: ["career", "relocation"], + datePrecision: "month_preferred", + freeTextAllowed: true, + }, + evidenceRecap: [{ + id: "37e0e35e-cfdc-4c7a-8375-84310ee6bd42", + summary: "2019 年换工作", + dateLabel: "2019-07", + }], + actions: ["answer", "pause", "abandon"], + pendingConsultationQuestion: null, + }; + + assert.equal(conversationalRectificationTurnSchema.safeParse(turn).success, true); + assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, candidateScores: [0.99] }).success, false); + assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, candidate: { ...turn.candidate, score: 0.99 } }).success, false); + assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, technicalReceipt: { ...turn.technicalReceipt, rawModelOutput: "secret" } }).success, false); +}); + +test("maps known domain failures to stable Chinese recovery copy", () => { + const stale = new ConversationalRectificationError("stale_turn"); + assert.deepEqual(stale.public, { + code: "stale_turn", + status: 409, + error: "校正进度已更新", + message: "请加载最新进度后再试。", + }); + + const recovered = toConversationalRectificationError(new Error("WebKit SyntaxError: SQL password=model secret")); + assert.deepEqual(recovered.public, { + code: "service_unavailable", + status: 503, + error: "生时校正暂时不可用", + message: "当前资料已安全保留,请稍后重试。", + }); + assert.doesNotMatch(recovered.public.message, /WebKit|SQL|model|secret/i); +});