1138 lines
37 KiB
TypeScript
1138 lines
37 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
ConversationalRectificationError,
|
|
toConversationalRectificationPublicError,
|
|
} from "../src/lib/conversational-rectification/errors.ts";
|
|
import {
|
|
ConversationalRectificationStore,
|
|
mapConversationalRectificationStoreError,
|
|
type ConversationalRectificationRpcClient,
|
|
} from "../src/lib/conversational-rectification/store.ts";
|
|
import { ConversationalRectificationBilling } from "../src/lib/conversational-rectification/billing.ts";
|
|
import { conversationalRectificationTurnSchema } from "../src/lib/conversational-rectification/contracts.ts";
|
|
import {
|
|
conversationalRectificationActionReceiptRequestSchema,
|
|
conversationalRectificationActionReceiptResponseSchema,
|
|
declaredBirthInputSchema,
|
|
lifeEventEvidenceSchema,
|
|
privateCandidateSchema,
|
|
validationReceiptSchema,
|
|
} from "../src/lib/conversational-rectification/persistence-contracts.ts";
|
|
import { postgresJsonbTextBytes } from "../src/lib/conversational-rectification/json-bounds.ts";
|
|
|
|
const userId = "00000000-0000-4000-8000-000000000101";
|
|
const caseId = "00000000-0000-4000-8000-000000000102";
|
|
const actionId = "00000000-0000-4000-8000-000000000103";
|
|
const resultId = "00000000-0000-4000-8000-000000000104";
|
|
const importCaseId = "00000000-0000-4000-8000-000000000107";
|
|
const commandFingerprint = "c".repeat(64);
|
|
|
|
const firstTurn = {
|
|
caseId,
|
|
journeyProtocol: "conversational-evidence-v3" as const,
|
|
status: "active" as const,
|
|
turnVersion: 0,
|
|
narrative: "我们会用已经发生的人生事件验证当前候选范围。",
|
|
candidate: {
|
|
status: "pending_validation" as const,
|
|
representativeTime: "05:21",
|
|
rangeStart: "05:10",
|
|
rangeEnd: "05:30",
|
|
},
|
|
technicalReceipt: {
|
|
calculationVersion: "rectification-v3.1",
|
|
stableLayers: ["D1"],
|
|
sensitiveLayers: ["D9"],
|
|
candidateDifferenceRefs: ["difference-1"],
|
|
},
|
|
evidenceRequest: {
|
|
domains: ["career", "relocation"] as const,
|
|
datePrecision: "month_preferred" as const,
|
|
freeTextAllowed: true as const,
|
|
},
|
|
evidenceRecap: [],
|
|
actions: ["answer", "pause", "abandon"] as const,
|
|
pendingConsultationQuestion: null,
|
|
};
|
|
|
|
const storedRow = {
|
|
case_id: caseId,
|
|
user_id: userId,
|
|
status: "active",
|
|
turn_version: 0,
|
|
revision_of_case_id: null,
|
|
imported_from_case_id: null,
|
|
baseline_active_time: "04:58",
|
|
pending_consultation_question: null,
|
|
billing_state: null,
|
|
latest_turn: firstTurn,
|
|
declared_birth_input: {
|
|
birthDate: "1990-01-01",
|
|
reportedTime: "05:20",
|
|
source: "approximate",
|
|
birthTimeClue: "家人记得天刚亮",
|
|
uncertaintyBeforeMinutes: 30,
|
|
uncertaintyAfterMinutes: 30,
|
|
birthplace: {
|
|
countryCode: "TW",
|
|
provinceCode: "TPE",
|
|
cityCode: "TPE-CITY",
|
|
districtCode: "DAAN",
|
|
latitude: 25.0268,
|
|
longitude: 121.5434,
|
|
timezoneOffset: 8,
|
|
},
|
|
},
|
|
private_candidate: {
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
candidateWeights: [0.6, 0.4],
|
|
},
|
|
event_evidence: [],
|
|
validation_receipts: [{ modelId: "synthetic-model", schemaValidated: true }],
|
|
};
|
|
|
|
const validationReceipt = {
|
|
modelId: "synthetic-model",
|
|
schemaValidated: true,
|
|
};
|
|
|
|
function rpcClient(
|
|
handler: (name: string, args: Readonly<Record<string, unknown>>) => unknown,
|
|
): ConversationalRectificationRpcClient {
|
|
return {
|
|
async rpc(name, args) {
|
|
return { data: handler(name, args), error: null };
|
|
},
|
|
};
|
|
}
|
|
|
|
test("creates an account-level case and first public turn through one RPC", async () => {
|
|
let call: { name: string; args: Readonly<Record<string, unknown>> } | undefined;
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
call = { name, args };
|
|
return storedRow;
|
|
}));
|
|
|
|
const result = await store.createCaseWithFirstTurn({
|
|
userId,
|
|
caseId,
|
|
actionId: caseId,
|
|
expectedVersion: 0,
|
|
revisionOfCaseId: null,
|
|
pendingConsultationQuestion: null,
|
|
declaredBirthInput: {
|
|
birthDate: "1990-01-01",
|
|
reportedTime: "05:20",
|
|
source: "approximate",
|
|
birthTimeClue: "家人记得天刚亮",
|
|
uncertaintyBeforeMinutes: 30,
|
|
uncertaintyAfterMinutes: 30,
|
|
birthplace: {
|
|
countryCode: "TW",
|
|
provinceCode: "TPE",
|
|
cityCode: "TPE-CITY",
|
|
districtCode: "DAAN",
|
|
latitude: 25.0268,
|
|
longitude: 121.5434,
|
|
timezoneOffset: 8,
|
|
},
|
|
},
|
|
firstTurn,
|
|
validationReceipt,
|
|
privateCandidate: {
|
|
resultId,
|
|
representativeTime: "05:21",
|
|
calculationVersion: "rectification-v3.1",
|
|
candidateWeights: [0.6, 0.4],
|
|
},
|
|
});
|
|
|
|
assert.equal(call?.name, "create_conversational_rectification_case");
|
|
assert.deepEqual(call?.args, {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_expected_version: 0,
|
|
p_action_id: caseId,
|
|
p_revision_of_case_id: null,
|
|
p_pending_consultation_question: null,
|
|
p_declared_birth_input: {
|
|
birthDate: "1990-01-01",
|
|
reportedTime: "05:20",
|
|
source: "approximate",
|
|
birthTimeClue: "家人记得天刚亮",
|
|
uncertaintyBeforeMinutes: 30,
|
|
uncertaintyAfterMinutes: 30,
|
|
birthplace: {
|
|
countryCode: "TW",
|
|
provinceCode: "TPE",
|
|
cityCode: "TPE-CITY",
|
|
districtCode: "DAAN",
|
|
latitude: 25.0268,
|
|
longitude: 121.5434,
|
|
timezoneOffset: 8,
|
|
},
|
|
},
|
|
p_first_turn: firstTurn,
|
|
p_validation_receipt: validationReceipt,
|
|
p_private_candidate: {
|
|
resultId,
|
|
representativeTime: "05:21",
|
|
calculationVersion: "rectification-v3.1",
|
|
candidateWeights: [0.6, 0.4],
|
|
},
|
|
});
|
|
assert.equal(result.caseId, caseId);
|
|
assert.equal(result.baselineActiveTime, "04:58");
|
|
assert.deepEqual(result.declaredBirthInput, storedRow.declared_birth_input);
|
|
assert.deepEqual(result.latestTurn, firstTurn);
|
|
assert.deepEqual(result.privateCandidate, storedRow.private_candidate);
|
|
assert.deepEqual(result.eventEvidence, []);
|
|
assert.deepEqual(result.validationReceipts, storedRow.validation_receipts);
|
|
});
|
|
|
|
test("omits the optional new-event follow-up marker for legacy first-turn SQL validators", async () => {
|
|
let firstTurnPayload: unknown;
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
assert.equal(name, "create_conversational_rectification_case");
|
|
firstTurnPayload = args.p_first_turn;
|
|
return storedRow;
|
|
}));
|
|
|
|
await store.createCaseWithFirstTurn({
|
|
userId,
|
|
caseId,
|
|
actionId: caseId,
|
|
expectedVersion: 0,
|
|
revisionOfCaseId: null,
|
|
pendingConsultationQuestion: null,
|
|
declaredBirthInput: storedRow.declared_birth_input as never,
|
|
firstTurn: {
|
|
...firstTurn,
|
|
evidenceRequest: {
|
|
...firstTurn.evidenceRequest,
|
|
followUp: { kind: "new_event", evidenceId: null },
|
|
},
|
|
},
|
|
validationReceipt,
|
|
privateCandidate: storedRow.private_candidate,
|
|
});
|
|
|
|
assert.deepEqual(
|
|
(firstTurnPayload as { evidenceRequest?: { followUp?: unknown } }).evidenceRequest,
|
|
{
|
|
domains: firstTurn.evidenceRequest.domains,
|
|
datePrecision: firstTurn.evidenceRequest.datePrecision,
|
|
freeTextAllowed: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("omits the optional new-event follow-up marker when saving a turn", async () => {
|
|
let turnPayload: unknown;
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
assert.equal(name, "save_conversational_rectification_turn_with_history");
|
|
assert.equal(args.p_user_message, "2019 年 7 月换工作");
|
|
turnPayload = args.p_turn;
|
|
return storedRow;
|
|
}));
|
|
|
|
await store.saveTurn({
|
|
userId,
|
|
caseId,
|
|
actionId: "00000000-0000-4000-8000-000000000099",
|
|
expectedVersion: 0,
|
|
commandFingerprint: "a".repeat(64),
|
|
userMessage: "2019 年 7 月换工作",
|
|
turn: {
|
|
...firstTurn,
|
|
turnVersion: 1,
|
|
evidenceRequest: {
|
|
...firstTurn.evidenceRequest,
|
|
followUp: { kind: "new_event", evidenceId: null },
|
|
},
|
|
},
|
|
evidence: [],
|
|
validationReceipt,
|
|
privateCandidate: storedRow.private_candidate,
|
|
});
|
|
|
|
assert.equal(
|
|
(turnPayload as { evidenceRequest?: { followUp?: unknown } }).evidenceRequest?.followUp,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("passes a null user message for an assistant-only regenerated turn", async () => {
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
assert.equal(name, "save_conversational_rectification_turn_with_history");
|
|
assert.equal(args.p_user_message, null);
|
|
return { ...storedRow, turn_version: 1 };
|
|
}));
|
|
|
|
await store.saveTurn({
|
|
userId,
|
|
caseId,
|
|
actionId: "00000000-0000-4000-8000-000000000098",
|
|
expectedVersion: 0,
|
|
commandFingerprint: "b".repeat(64),
|
|
userMessage: null,
|
|
turn: { ...firstTurn, turnVersion: 1 },
|
|
evidence: [],
|
|
validationReceipt,
|
|
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
|
|
});
|
|
});
|
|
|
|
test("loads the latest unfinished case by account without a chat identifier", async () => {
|
|
const calls: unknown[] = [];
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
calls.push([name, args]);
|
|
return storedRow;
|
|
}));
|
|
|
|
const loaded = await store.loadCase({ userId });
|
|
assert.equal(loaded?.caseId, caseId);
|
|
assert.deepEqual(calls, [["load_conversational_rectification_case_with_history", {
|
|
p_user_id: userId,
|
|
p_case_id: null,
|
|
}]]);
|
|
});
|
|
|
|
test("loads an exact owner-scoped historical mutation receipt before current case state", async () => {
|
|
const calls: unknown[] = [];
|
|
const publicReceiptRow: Partial<typeof storedRow> = { ...storedRow };
|
|
delete publicReceiptRow.declared_birth_input;
|
|
delete publicReceiptRow.private_candidate;
|
|
delete publicReceiptRow.event_evidence;
|
|
delete publicReceiptRow.validation_receipts;
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
calls.push([name, args]);
|
|
return publicReceiptRow;
|
|
}));
|
|
|
|
const replayed = await store.loadActionReceipt({
|
|
userId,
|
|
caseId,
|
|
actionId,
|
|
actionKind: "save_turn",
|
|
expectedVersion: 0,
|
|
commandFingerprint,
|
|
});
|
|
|
|
assert.equal(replayed?.caseId, caseId);
|
|
assert.deepEqual(replayed?.latestTurn, firstTurn);
|
|
assert.equal(replayed && "privateCandidate" in replayed, false);
|
|
assert.deepEqual(calls, [["replay_conversational_rectification_action", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_action_id: actionId,
|
|
p_action_kind: "save_turn",
|
|
p_expected_version: 0,
|
|
p_command_fingerprint: commandFingerprint,
|
|
}]]);
|
|
});
|
|
|
|
test("binds every paid start to the public action as its recoverable case id", async () => {
|
|
let calls = 0;
|
|
const client = rpcClient(() => {
|
|
calls += 1;
|
|
return storedRow;
|
|
});
|
|
const store = new ConversationalRectificationStore(client);
|
|
const billing = new ConversationalRectificationBilling(client);
|
|
|
|
await assert.rejects(
|
|
billing.reserve({ userId, caseId, actionId, expectedVersion: 0, price: 3 }),
|
|
(error: unknown) => error instanceof ConversationalRectificationError
|
|
&& error.code === "action_conflict",
|
|
);
|
|
await assert.rejects(
|
|
store.createCaseWithFirstTurn({
|
|
userId,
|
|
caseId,
|
|
actionId,
|
|
expectedVersion: 0,
|
|
revisionOfCaseId: null,
|
|
pendingConsultationQuestion: null,
|
|
declaredBirthInput: storedRow.declared_birth_input as never,
|
|
firstTurn,
|
|
validationReceipt,
|
|
privateCandidate: storedRow.private_candidate,
|
|
}),
|
|
(error: unknown) => error instanceof ConversationalRectificationError
|
|
&& error.code === "action_conflict",
|
|
);
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test("save, pause, abandon, confirm, and import carry owner/version/action guards", async () => {
|
|
const calls: Array<[string, Readonly<Record<string, unknown>>]> = [];
|
|
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
|
|
calls.push([name, args]);
|
|
return storedRow;
|
|
}));
|
|
const common = { userId, caseId, actionId, expectedVersion: 0, commandFingerprint };
|
|
const evidence = [{
|
|
id: "00000000-0000-4000-8000-000000000105",
|
|
rawText: "2019 年 7 月换工作",
|
|
domain: "career" as const,
|
|
eventSummary: "换工作",
|
|
dateValue: "2019-07",
|
|
datePrecision: "month" as const,
|
|
extractionStatus: "corrected" as const,
|
|
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
|
}];
|
|
|
|
await store.saveTurn({
|
|
...common,
|
|
userMessage: "2019 年 7 月换工作",
|
|
turn: { ...firstTurn, turnVersion: 1 },
|
|
evidence,
|
|
validationReceipt,
|
|
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
|
|
});
|
|
await store.pause({
|
|
...common,
|
|
turn: { ...firstTurn, status: "paused", turnVersion: 1 },
|
|
validationReceipt,
|
|
});
|
|
await store.abandon({
|
|
...common,
|
|
turn: { ...firstTurn, status: "abandoned", turnVersion: 1 },
|
|
validationReceipt,
|
|
});
|
|
await store.confirm({
|
|
...common,
|
|
resultId,
|
|
time: "05:21",
|
|
calculationVersion: "rectification-v3.1",
|
|
validationReceipt,
|
|
turn: {
|
|
...firstTurn,
|
|
status: "completed",
|
|
turnVersion: 1,
|
|
candidate: { ...firstTurn.candidate, status: "confirmed" },
|
|
evidenceRequest: null,
|
|
actions: ["continue_original_question"],
|
|
},
|
|
});
|
|
await store.importLegacy({
|
|
userId,
|
|
caseId: importCaseId,
|
|
actionId: importCaseId,
|
|
expectedVersion: 0,
|
|
legacyCaseId: "00000000-0000-4000-8000-000000000106",
|
|
price: 3,
|
|
pendingConsultationQuestion: null,
|
|
declaredBirthInput: declaredBirthInputSchema.parse(storedRow.declared_birth_input),
|
|
evidence: [],
|
|
firstTurn: { ...firstTurn, caseId: importCaseId },
|
|
validationReceipt,
|
|
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
|
|
});
|
|
|
|
assert.deepEqual(calls.map(([name]) => name), [
|
|
"save_conversational_rectification_turn_with_history",
|
|
"pause_conversational_rectification_case",
|
|
"abandon_conversational_rectification_without_result",
|
|
"confirm_conversational_rectification_candidate",
|
|
"import_legacy_conversational_rectification_case",
|
|
]);
|
|
for (const [, args] of calls.slice(0, 4)) {
|
|
assert.equal(args.p_user_id, userId);
|
|
assert.equal(args.p_case_id, caseId);
|
|
assert.equal(args.p_action_id, actionId);
|
|
assert.equal(args.p_expected_version, 0);
|
|
assert.equal(args.p_command_fingerprint, commandFingerprint);
|
|
assert.deepEqual(args.p_validation_receipt, validationReceipt);
|
|
}
|
|
assert.equal(calls[4]?.[1].p_case_id, importCaseId);
|
|
assert.equal(calls[4]?.[1].p_action_id, importCaseId);
|
|
assert.deepEqual(calls[4]?.[1].p_validation_receipt, validationReceipt);
|
|
assert.deepEqual(calls[0]?.[1].p_evidence, evidence);
|
|
assert.equal(calls[0]?.[1].p_turn && typeof calls[0]?.[1].p_turn, "object");
|
|
assert.equal("outputValidationReceipt" in (calls[0]?.[1].p_turn as object), false);
|
|
});
|
|
|
|
test("a completed unverified range uses the non-confirming completion RPC", async () => {
|
|
let called = "";
|
|
const completedTurn = {
|
|
...firstTurn,
|
|
status: "completed" as const,
|
|
turnVersion: 1,
|
|
evidenceRequest: null,
|
|
actions: [] as const,
|
|
};
|
|
const store = new ConversationalRectificationStore(rpcClient((name) => {
|
|
called = name;
|
|
return { ...storedRow, status: "completed", turn_version: 1, latest_turn: completedTurn };
|
|
}));
|
|
|
|
const result = await store.saveTurn({
|
|
userId,
|
|
caseId,
|
|
actionId,
|
|
expectedVersion: 0,
|
|
commandFingerprint,
|
|
userMessage: "这些经历已经补充完了",
|
|
turn: completedTurn,
|
|
evidence: [],
|
|
validationReceipt,
|
|
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
|
|
});
|
|
|
|
assert.equal(called, "complete_conversational_rectification_with_range_and_history");
|
|
assert.equal(result.status, "completed");
|
|
assert.equal(result.latestTurn.candidate.status, "pending_validation");
|
|
});
|
|
|
|
test("maps only exact allowlisted database failures to stable domain codes", () => {
|
|
const exact = [
|
|
["conversational_case_not_found", "case_not_found"],
|
|
["conversational_stale_turn", "stale_turn"],
|
|
["conversational_action_conflict", "action_conflict"],
|
|
["conversational_candidate_changed", "candidate_changed"],
|
|
["conversational_billing_failed", "billing_failed"],
|
|
] as const;
|
|
for (const [message, code] of exact) {
|
|
const mapped = mapConversationalRectificationStoreError({ code: "P0001", message });
|
|
assert.ok(mapped instanceof ConversationalRectificationError);
|
|
assert.equal(mapped.code, code);
|
|
}
|
|
assert.equal(
|
|
mapConversationalRectificationStoreError({ code: "08006", message: "conversational_stale_turn" }).code,
|
|
"store_unavailable",
|
|
);
|
|
assert.equal(
|
|
mapConversationalRectificationStoreError({ code: "P0001", message: "raw database detail" }).code,
|
|
"store_unavailable",
|
|
);
|
|
});
|
|
|
|
test("unknown Supabase errors never survive in the store or public error", async () => {
|
|
const rawMessage = "WebKit SQL password=model-secret";
|
|
const store = new ConversationalRectificationStore({
|
|
async rpc() {
|
|
return { data: null, error: { code: "08006", message: rawMessage } };
|
|
},
|
|
});
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await store.loadCase({ userId, caseId });
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
assert.ok(caught instanceof ConversationalRectificationError);
|
|
assert.equal(caught.code, "store_unavailable");
|
|
assert.equal(JSON.stringify(caught).includes(rawMessage), false);
|
|
assert.equal(JSON.stringify(toConversationalRectificationPublicError(caught)).includes(rawMessage), false);
|
|
});
|
|
|
|
test("billing sends the server fee and never converts completion into a debit", async () => {
|
|
const calls: Array<[string, Readonly<Record<string, unknown>>]> = [];
|
|
const billing = new ConversationalRectificationBilling(rpcClient((name, args) => {
|
|
calls.push([name, args]);
|
|
return {
|
|
success: true,
|
|
credits: 7,
|
|
billing_state: name.includes("complete") ? "charged" : name.includes("release") ? "released" : "reserved",
|
|
error_code: null,
|
|
};
|
|
}));
|
|
const common = { userId, caseId, actionId: caseId, expectedVersion: 0 };
|
|
|
|
assert.equal((await billing.reserve({ ...common, price: 3 })).billingState, "reserved");
|
|
assert.equal((await billing.complete(common)).billingState, "charged");
|
|
assert.equal((await billing.release({ ...common, price: 3 })).billingState, "released");
|
|
|
|
assert.deepEqual(calls.map(([name]) => name), [
|
|
"reserve_conversational_rectification_fee",
|
|
"complete_conversational_rectification_fee",
|
|
"release_conversational_rectification_fee",
|
|
]);
|
|
assert.equal(calls[0]?.[1].p_price, 3);
|
|
assert.equal("p_price" in (calls[1]?.[1] ?? {}), false);
|
|
assert.equal(calls[2]?.[1].p_price, 3);
|
|
});
|
|
|
|
test("billing rejections use stable domain errors instead of raw RPC messages", async () => {
|
|
const insufficient = new ConversationalRectificationBilling(rpcClient(() => ({
|
|
success: false,
|
|
credits: 0,
|
|
billing_state: null,
|
|
error_code: "insufficient_credits",
|
|
})));
|
|
await assert.rejects(
|
|
insufficient.reserve({ userId, caseId, actionId: caseId, expectedVersion: 0, price: 3 }),
|
|
(error: unknown) => error instanceof ConversationalRectificationError
|
|
&& error.code === "insufficient_credits",
|
|
);
|
|
|
|
const failed = new ConversationalRectificationBilling(rpcClient(() => ({
|
|
success: false,
|
|
credits: 7,
|
|
billing_state: "released",
|
|
error_code: "already_charged",
|
|
})));
|
|
await assert.rejects(
|
|
failed.release({ userId, caseId, actionId: caseId, expectedVersion: 0, price: 3 }),
|
|
(error: unknown) => error instanceof ConversationalRectificationError
|
|
&& error.code === "billing_failed",
|
|
);
|
|
});
|
|
|
|
test("declared birth input is strict, source-aware, bounded, and location-complete", () => {
|
|
assert.equal(declaredBirthInputSchema.safeParse(storedRow.declared_birth_input).success, true);
|
|
const commonDeclaration = {
|
|
birthDate: "1990-01-01",
|
|
birthTimeClue: null,
|
|
birthplace: {
|
|
city: "Taipei",
|
|
latitude: 25.03,
|
|
longitude: 121.56,
|
|
timezoneOffset: 8,
|
|
},
|
|
};
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...commonDeclaration,
|
|
source: "unknown",
|
|
birthplace: {
|
|
city: "台湾 · 台北市",
|
|
placeId: "1668341",
|
|
placeType: "locality",
|
|
provider: "geonames",
|
|
countryCode: "TW",
|
|
latitude: 25.03,
|
|
longitude: 121.56,
|
|
timezoneId: "Asia/Taipei",
|
|
timezoneSource: "iana_historical",
|
|
timezoneOffset: 8,
|
|
},
|
|
}).success, true);
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...commonDeclaration,
|
|
source: "unknown",
|
|
birthplace: {
|
|
placeId: "provider-place-id",
|
|
latitude: 40.7128,
|
|
longitude: -74.006,
|
|
timezoneId: "America/New_York",
|
|
timezoneOffset: -5,
|
|
},
|
|
}).success, true);
|
|
for (const value of [
|
|
{ ...commonDeclaration, source: "hospital_record", reportedTime: "05:20", uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2 },
|
|
{ ...commonDeclaration, source: "family_exact", reportedTime: "05:20", uncertaintyBeforeMinutes: 10, uncertaintyAfterMinutes: 10 },
|
|
{ ...commonDeclaration, source: "period_only", reportedPeriod: "morning" },
|
|
{ ...commonDeclaration, source: "unknown" },
|
|
{ ...commonDeclaration, source: "legacy_import", reportedTime: "05:20", uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0 },
|
|
]) {
|
|
assert.equal(declaredBirthInputSchema.safeParse(value).success, true);
|
|
}
|
|
|
|
const invalid = [
|
|
{ ...storedRow.declared_birth_input, unexpected: true },
|
|
{ ...storedRow.declared_birth_input, birthDate: "2023-02-30" },
|
|
{ ...storedRow.declared_birth_input, birthTimeClue: undefined },
|
|
{ ...storedRow.declared_birth_input, birthplace: undefined },
|
|
{ ...storedRow.declared_birth_input, source: "unrecognized" },
|
|
{
|
|
...storedRow.declared_birth_input,
|
|
uncertaintyAfterMinutes: 60,
|
|
},
|
|
{
|
|
...storedRow.declared_birth_input,
|
|
reportedPeriod: "morning",
|
|
},
|
|
{
|
|
...storedRow.declared_birth_input,
|
|
birthplace: { ...storedRow.declared_birth_input.birthplace, latitude: 91 },
|
|
},
|
|
{
|
|
...storedRow.declared_birth_input,
|
|
birthplace: { timezoneOffset: 8 },
|
|
},
|
|
];
|
|
for (const value of invalid) {
|
|
assert.equal(declaredBirthInputSchema.safeParse(value).success, false);
|
|
}
|
|
const exactClue = declaredBirthInputSchema.parse({
|
|
...storedRow.declared_birth_input,
|
|
birthTimeClue: " exact clue spacing ",
|
|
});
|
|
assert.equal(exactClue.birthTimeClue, " exact clue spacing ");
|
|
});
|
|
|
|
test("durable JSON uses PostgreSQL-stable numeric vectors recursively", () => {
|
|
const stableVector = {
|
|
zero: 0,
|
|
minFraction: 0.000001,
|
|
ieeeRoundingBoundary: 1.000001,
|
|
decimal: 0.123456,
|
|
maxSafe: 9_007_199_254_740_991,
|
|
};
|
|
assert.equal(postgresJsonbTextBytes(stableVector), 120);
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...storedRow.declared_birth_input,
|
|
birthplace: {
|
|
...storedRow.declared_birth_input.birthplace,
|
|
latitude: 25.0268,
|
|
longitude: 121.5434,
|
|
timezoneOffset: 8,
|
|
},
|
|
}).success, true);
|
|
|
|
assert.ok(Number.isSafeInteger(1.000001 * 1_000_000) === false);
|
|
for (const candidateWeights of [[1e-7], [1e-100], [0.0000012], [0.1234567]]) {
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
candidateWeights,
|
|
}).success, false);
|
|
}
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
scoredHistoricalEvidence: [{
|
|
evidenceId: "00000000-0000-4000-8000-000000000105",
|
|
domain: "career",
|
|
candidateTime: "05:21",
|
|
score: 1e-100,
|
|
ruleRefs: [],
|
|
}],
|
|
}).success, false);
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...storedRow.declared_birth_input,
|
|
birthplace: {
|
|
...storedRow.declared_birth_input.birthplace,
|
|
latitude: 1e-100,
|
|
},
|
|
}).success, false);
|
|
assert.equal(postgresJsonbTextBytes({ nested: [{ score: 1e-100 }] }), Number.POSITIVE_INFINITY);
|
|
});
|
|
|
|
test("durable text uses ECMAScript nonblank and UTF-16 maximum semantics", () => {
|
|
const unicodeWhitespace = "\u00a0\u2007\ufeff";
|
|
const validAstral80 = "😀".repeat(40);
|
|
const invalidAstral82 = "😀".repeat(41);
|
|
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: "😀".repeat(60),
|
|
schemaValidated: true,
|
|
}).success, true);
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: "😀".repeat(61),
|
|
schemaValidated: true,
|
|
}).success, false);
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: validAstral80,
|
|
}).success, true);
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: invalidAstral82,
|
|
}).success, false);
|
|
|
|
for (const invalid of [
|
|
{ ...firstTurn, narrative: unicodeWhitespace },
|
|
{
|
|
...firstTurn,
|
|
technicalReceipt: { ...firstTurn.technicalReceipt, calculationVersion: unicodeWhitespace },
|
|
},
|
|
{
|
|
...firstTurn,
|
|
technicalReceipt: { ...firstTurn.technicalReceipt, stableLayers: [unicodeWhitespace] },
|
|
},
|
|
{
|
|
...firstTurn,
|
|
evidenceRecap: [{ id: resultId, summary: unicodeWhitespace, dateLabel: "2020-01" }],
|
|
},
|
|
{ ...firstTurn, pendingConsultationQuestion: unicodeWhitespace },
|
|
]) {
|
|
assert.equal(conversationalRectificationTurnSchema.safeParse(invalid).success, false);
|
|
}
|
|
|
|
for (const invalid of [
|
|
{ modelId: unicodeWhitespace, schemaValidated: true },
|
|
{ modelId: "model", schemaValidated: true, validatorVersion: unicodeWhitespace },
|
|
{ modelId: "model", schemaValidated: true, issues: [unicodeWhitespace] },
|
|
]) {
|
|
assert.equal(validationReceiptSchema.safeParse(invalid).success, false);
|
|
}
|
|
|
|
for (const invalid of [
|
|
{ resultId, calculationVersion: unicodeWhitespace },
|
|
{ resultId, calculationVersion: "v1", candidateModelRefs: [unicodeWhitespace] },
|
|
{
|
|
resultId,
|
|
calculationVersion: "v1",
|
|
futureWindows: [{
|
|
label: unicodeWhitespace,
|
|
startDate: "2020-01-01",
|
|
endDate: "2020-01-02",
|
|
scoreable: false,
|
|
}],
|
|
},
|
|
{
|
|
resultId,
|
|
calculationVersion: "v1",
|
|
workingState: { phase: "initial", iteration: 0, notes: [unicodeWhitespace] },
|
|
},
|
|
]) {
|
|
assert.equal(privateCandidateSchema.safeParse(invalid).success, false);
|
|
}
|
|
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...storedRow.declared_birth_input,
|
|
birthTimeClue: unicodeWhitespace,
|
|
}).success, false);
|
|
assert.equal(declaredBirthInputSchema.safeParse({
|
|
...storedRow.declared_birth_input,
|
|
birthplace: { ...storedRow.declared_birth_input.birthplace, city: unicodeWhitespace },
|
|
}).success, false);
|
|
});
|
|
|
|
test("evidence recap enforces the SQL-matched aggregate byte limit", () => {
|
|
const evidenceRecap = Array.from({ length: 9 }, (_, index) => ({
|
|
id: `00000000-0000-4000-8000-${(700 + index).toString().padStart(12, "0")}`,
|
|
summary: "事".repeat(900),
|
|
dateLabel: "2020-01",
|
|
}));
|
|
const turn = { ...firstTurn, evidenceRecap };
|
|
|
|
assert.ok(postgresJsonbTextBytes(evidenceRecap) > 24_576);
|
|
assert.ok(postgresJsonbTextBytes(turn) < 65_536);
|
|
assert.equal(conversationalRectificationTurnSchema.safeParse(turn).success, false);
|
|
});
|
|
|
|
test("nested receipt and private-candidate arrays match SQL UTF-8 byte caps", () => {
|
|
const fullLayer = "事".repeat(80);
|
|
const receiptAtLimit = [
|
|
...Array.from({ length: 16 }, () => fullLayer),
|
|
`${"事".repeat(62)}aa`,
|
|
];
|
|
const receiptOverLimit = [
|
|
...Array.from({ length: 16 }, () => fullLayer),
|
|
"事".repeat(61),
|
|
"aa",
|
|
];
|
|
assert.equal(postgresJsonbTextBytes(receiptAtLimit), 4_096);
|
|
assert.equal(postgresJsonbTextBytes(receiptOverLimit), 4_097);
|
|
|
|
for (const field of ["stableLayers", "sensitiveLayers"] as const) {
|
|
assert.equal(conversationalRectificationTurnSchema.safeParse({
|
|
...firstTurn,
|
|
technicalReceipt: { ...firstTurn.technicalReceipt, [field]: receiptAtLimit },
|
|
}).success, true, `${field} at its SQL byte limit`);
|
|
assert.equal(conversationalRectificationTurnSchema.safeParse({
|
|
...firstTurn,
|
|
technicalReceipt: { ...firstTurn.technicalReceipt, [field]: receiptOverLimit },
|
|
}).success, false, `${field} above its SQL byte limit`);
|
|
}
|
|
|
|
const fullModelReference = "事".repeat(120);
|
|
const modelReferencesAtLimit = [
|
|
...Array.from({ length: 44 }, () => fullModelReference),
|
|
"事".repeat(119),
|
|
"aaa",
|
|
];
|
|
const modelReferencesOverLimit = [
|
|
...Array.from({ length: 44 }, () => fullModelReference),
|
|
"事".repeat(119),
|
|
"aaaa",
|
|
];
|
|
assert.equal(postgresJsonbTextBytes(modelReferencesAtLimit), 16_384);
|
|
assert.equal(postgresJsonbTextBytes(modelReferencesOverLimit), 16_385);
|
|
|
|
const supportedLayersAtLimit = [
|
|
...Array.from({ length: 33 }, () => fullLayer),
|
|
`${"事".repeat(45)}a`,
|
|
];
|
|
const supportedLayersOverLimit = [
|
|
...Array.from({ length: 33 }, () => fullLayer),
|
|
`${"事".repeat(45)}aa`,
|
|
];
|
|
assert.equal(postgresJsonbTextBytes(supportedLayersAtLimit), 8_192);
|
|
assert.equal(postgresJsonbTextBytes(supportedLayersOverLimit), 8_193);
|
|
|
|
for (const [field, atLimit, overLimit] of [
|
|
["candidateModelRefs", modelReferencesAtLimit, modelReferencesOverLimit],
|
|
["supportedSensitiveLayers", supportedLayersAtLimit, supportedLayersOverLimit],
|
|
] as const) {
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
[field]: atLimit,
|
|
}).success, true, `${field} at its SQL byte limit`);
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
[field]: overLimit,
|
|
}).success, false, `${field} above its SQL byte limit`);
|
|
}
|
|
});
|
|
|
|
test("life-event evidence rejects unknown, blank, and non-boolean durable values", () => {
|
|
const evidence = {
|
|
id: "00000000-0000-4000-8000-000000000105",
|
|
rawText: "2019 年 7 月换工作",
|
|
domain: "career" as const,
|
|
eventSummary: "换工作",
|
|
dateValue: "2019-07",
|
|
datePrecision: "month" as const,
|
|
extractionStatus: "clear" as const,
|
|
scoreable: true,
|
|
};
|
|
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
|
assert.equal(lifeEventEvidenceSchema.safeParse({
|
|
...evidence,
|
|
extractionStatus: "corrected",
|
|
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
|
}).success, true);
|
|
assert.equal(lifeEventEvidenceSchema.safeParse({
|
|
...evidence,
|
|
extractionStatus: "needs_clarification",
|
|
scoreable: false,
|
|
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
|
}).success, true);
|
|
for (const invalid of [
|
|
{ ...evidence, extra: "discard me" },
|
|
{ ...evidence, eventSummary: " \t " },
|
|
{ ...evidence, dateValue: " \t " },
|
|
{ ...evidence, scoreable: null },
|
|
{ ...evidence, scoreable: "true" },
|
|
{ ...evidence, extractionStatus: "corrected", correctsEvidenceIds: [] },
|
|
{ ...evidence, correctsEvidenceIds: ["not-a-uuid"] },
|
|
{
|
|
...evidence,
|
|
extractionStatus: "corrected",
|
|
correctsEvidenceIds: [
|
|
"00000000-0000-4000-8000-000000000109",
|
|
"00000000-0000-4000-8000-000000000110",
|
|
],
|
|
},
|
|
]) {
|
|
assert.equal(lifeEventEvidenceSchema.safeParse(invalid).success, false);
|
|
}
|
|
});
|
|
|
|
test("durable private and receipt schemas accept boundaries and reject oversize or unknown fields", () => {
|
|
assert.equal(postgresJsonbTextBytes({ a: [1, 2] }), 13);
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: "m".repeat(120),
|
|
schemaValidated: true,
|
|
validatorVersion: "v".repeat(80),
|
|
retryCount: 2,
|
|
fallbackUsed: false,
|
|
issues: ["i".repeat(240)],
|
|
}).success, true);
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: "m".repeat(121),
|
|
schemaValidated: true,
|
|
}).success, false);
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: `${"m".repeat(120)} `,
|
|
schemaValidated: true,
|
|
}).success, false);
|
|
assert.equal(validationReceiptSchema.safeParse({
|
|
modelId: "model",
|
|
schemaValidated: true,
|
|
validatedAt: "2026-07-20T12:00:00.123456789012345678901Z",
|
|
}).success, false);
|
|
|
|
const candidate = {
|
|
resultId,
|
|
representativeTime: "05:21",
|
|
calculationVersion: "rectification-v3.1",
|
|
candidateWeights: Array.from({ length: 1_440 }, () => 0.5),
|
|
candidateModelRefs: ["model-ref"],
|
|
suggestedDomains: ["career", "relocation"],
|
|
};
|
|
assert.equal(privateCandidateSchema.safeParse(candidate).success, true);
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
...candidate,
|
|
candidateWeights: [...candidate.candidateWeights, 0.5],
|
|
}).success, false);
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
calculationVersion: "v1",
|
|
rangeStart: null,
|
|
}).success, false);
|
|
assert.equal(privateCandidateSchema.safeParse({ ...candidate, secretExtra: true }).success, false);
|
|
|
|
const request = {
|
|
kind: "save_turn",
|
|
userId,
|
|
caseId,
|
|
expectedVersion: 0,
|
|
actionId,
|
|
requestFingerprint: "a".repeat(64),
|
|
commandFingerprint,
|
|
};
|
|
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse(request).success, true);
|
|
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
|
|
...request,
|
|
extra: "not durable",
|
|
}).success, false);
|
|
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
|
|
...request,
|
|
requestFingerprint: "a".repeat(65),
|
|
}).success, false);
|
|
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
|
|
...request,
|
|
commandFingerprint: "c".repeat(63),
|
|
}).success, false);
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
success: false,
|
|
credits: 7,
|
|
billing_state: null,
|
|
error_code: "e".repeat(80),
|
|
}).success, true);
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
success: false,
|
|
credits: 7,
|
|
billing_state: null,
|
|
error_code: "e".repeat(81),
|
|
}).success, false);
|
|
});
|
|
|
|
test("durable UUID text is canonical hyphenated syntax and case-insensitive", () => {
|
|
const canonical = "a9890e09-d535-46f0-9a36-86017515a5a1";
|
|
const compact = canonical.replaceAll("-", "");
|
|
const braced = `{${canonical}}`;
|
|
|
|
for (const validResultId of [canonical, canonical.toUpperCase()]) {
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId: validResultId,
|
|
calculationVersion: "v1",
|
|
}).success, true);
|
|
}
|
|
for (const invalidResultId of [compact, braced]) {
|
|
assert.equal(privateCandidateSchema.safeParse({
|
|
resultId: invalidResultId,
|
|
calculationVersion: "v1",
|
|
}).success, false);
|
|
assert.equal(conversationalRectificationTurnSchema.safeParse({
|
|
...firstTurn,
|
|
evidenceRecap: [{ id: invalidResultId, summary: "summary", dateLabel: "2020-01" }],
|
|
}).success, false);
|
|
}
|
|
});
|
|
|
|
test("public turn JSON fields reject field and byte boundary violations", () => {
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
...storedRow,
|
|
declared_birth_input: undefined,
|
|
private_candidate: undefined,
|
|
event_evidence: undefined,
|
|
validation_receipts: undefined,
|
|
}).success, true);
|
|
|
|
const exact = {
|
|
...firstTurn,
|
|
technicalReceipt: {
|
|
...firstTurn.technicalReceipt,
|
|
calculationVersion: "v".repeat(80),
|
|
},
|
|
evidenceRecap: [{
|
|
id: "00000000-0000-4000-8000-000000000108",
|
|
summary: "事".repeat(1_000),
|
|
dateLabel: "d".repeat(80),
|
|
}],
|
|
};
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
...storedRow,
|
|
latest_turn: exact,
|
|
declared_birth_input: undefined,
|
|
private_candidate: undefined,
|
|
event_evidence: undefined,
|
|
validation_receipts: undefined,
|
|
}).success, true);
|
|
|
|
const nearTurnLimit = {
|
|
...exact,
|
|
narrative: "n".repeat(12_000),
|
|
evidenceRecap: Array.from({ length: 7 }, (_, index) => ({
|
|
id: `00000000-0000-4000-8000-${(300 + index).toString().padStart(12, "0")}`,
|
|
summary: "事".repeat(1_000),
|
|
dateLabel: "d".repeat(80),
|
|
})),
|
|
};
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
...storedRow,
|
|
latest_turn: nearTurnLimit,
|
|
declared_birth_input: undefined,
|
|
private_candidate: undefined,
|
|
event_evidence: undefined,
|
|
validation_receipts: undefined,
|
|
}).success, true);
|
|
|
|
for (const latest_turn of [
|
|
{
|
|
...exact,
|
|
candidate: { ...exact.candidate, unknown: true },
|
|
},
|
|
{
|
|
...exact,
|
|
technicalReceipt: { ...exact.technicalReceipt, calculationVersion: "v".repeat(81) },
|
|
},
|
|
{
|
|
...exact,
|
|
evidenceRecap: [{ ...exact.evidenceRecap[0], summary: "事".repeat(1_001) }],
|
|
},
|
|
{
|
|
...exact,
|
|
narrative: "n".repeat(12_000),
|
|
evidenceRecap: Array.from({ length: 20 }, (_, index) => ({
|
|
id: `00000000-0000-4000-8000-${(200 + index).toString().padStart(12, "0")}`,
|
|
summary: "事".repeat(1_000),
|
|
dateLabel: "d".repeat(80),
|
|
})),
|
|
},
|
|
]) {
|
|
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
|
|
...storedRow,
|
|
latest_turn,
|
|
declared_birth_input: undefined,
|
|
private_candidate: undefined,
|
|
event_evidence: undefined,
|
|
validation_receipts: undefined,
|
|
}).success, false);
|
|
}
|
|
});
|
|
|
|
test("store rejects invalid durable inputs before issuing an RPC", async () => {
|
|
let calls = 0;
|
|
const store = new ConversationalRectificationStore(rpcClient(() => {
|
|
calls += 1;
|
|
return storedRow;
|
|
}));
|
|
|
|
await assert.rejects(store.createCaseWithFirstTurn({
|
|
userId,
|
|
caseId,
|
|
actionId: caseId,
|
|
expectedVersion: 0,
|
|
revisionOfCaseId: null,
|
|
pendingConsultationQuestion: null,
|
|
declaredBirthInput: {
|
|
birthDate: "1990-01-01",
|
|
source: "approximate",
|
|
reportedTime: "05:20",
|
|
birthTimeClue: null,
|
|
uncertaintyBeforeMinutes: 30,
|
|
uncertaintyAfterMinutes: 30,
|
|
} as never,
|
|
firstTurn,
|
|
validationReceipt,
|
|
privateCandidate: {
|
|
resultId,
|
|
calculationVersion: "rectification-v3.1",
|
|
},
|
|
}), (error: unknown) => error instanceof ConversationalRectificationError
|
|
&& error.code === "action_conflict");
|
|
assert.equal(calls, 0);
|
|
});
|