feat: define conversational rectification contract
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user