2295 lines
90 KiB
TypeScript
2295 lines
90 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import {
|
||
createConversationalRectificationService,
|
||
type ConversationalRectificationServicePorts,
|
||
} from "../src/lib/conversational-rectification/orchestrator.ts";
|
||
import { conversationalRectificationTurnSchema } from "../src/lib/conversational-rectification/contracts.ts";
|
||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
|
||
import type {
|
||
ConversationalRectificationTurnInput,
|
||
LoadedConversationalRectificationCase,
|
||
StoredConversationalRectificationCase,
|
||
} from "../src/lib/conversational-rectification/store.ts";
|
||
|
||
const userId = "00000000-0000-4000-8000-000000000701";
|
||
const startActionId = "00000000-0000-4000-8000-000000000702";
|
||
const answerActionId = "00000000-0000-4000-8000-000000000703";
|
||
const pauseActionId = "00000000-0000-4000-8000-000000000704";
|
||
const resumeActionId = "00000000-0000-4000-8000-000000000705";
|
||
const confirmActionId = "00000000-0000-4000-8000-000000000706";
|
||
const priorCaseId = "00000000-0000-4000-8000-000000000707";
|
||
const resultId = "00000000-0000-4000-8000-000000000708";
|
||
const laterActionId = "00000000-0000-4000-8000-000000000710";
|
||
const secondAnswerActionId = "00000000-0000-4000-8000-000000000711";
|
||
const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712";
|
||
const fourthAnswerActionId = "00000000-0000-4000-8000-000000000713";
|
||
const fifthAnswerActionId = "00000000-0000-4000-8000-000000000715";
|
||
const secondStartActionId = "00000000-0000-4000-8000-000000000714";
|
||
|
||
const declaredBirthInput = {
|
||
source: "approximate" as const,
|
||
birthDate: "2000-01-01",
|
||
reportedTime: "05:20",
|
||
uncertaintyBeforeMinutes: 30 as const,
|
||
uncertaintyAfterMinutes: 30 as const,
|
||
birthTimeClue: null,
|
||
birthplace: {
|
||
countryCode: "CN",
|
||
provinceCode: "110000",
|
||
cityCode: "110000-city",
|
||
latitude: 39.9042,
|
||
longitude: 116.4074,
|
||
timezoneOffset: 8,
|
||
},
|
||
};
|
||
|
||
function packet(ready = false): RectificationTechnicalPacket {
|
||
return {
|
||
calculationVersion: "rectification-technical-v1",
|
||
candidate: {
|
||
status: ready ? "ready_for_confirmation" : "pending_validation",
|
||
representativeTime: ready ? "05:18" : "05:20",
|
||
range: ready
|
||
? { startTime: "05:16", endTime: "05:20" }
|
||
: { startTime: "04:50", endTime: "05:50" },
|
||
},
|
||
useBoundary: ready
|
||
? "该候选已达到确认门槛,但必须由用户明确确认后才能替换当前排盘时间。"
|
||
: "该时间仅是待验证候选,必须由用户明确确认后才能替换当前排盘时间。",
|
||
candidateModelRefs: ["candidate-model-v1"],
|
||
candidateDifferenceRefs: ["consult-d1", "consult-d9", "consult-d10"],
|
||
candidateWeights: { "05:16": 0.4, "05:18": 0.6 },
|
||
partitionIds: ["private-partition"],
|
||
d1Stability: "stable",
|
||
boundaryDistanceMinutes: 2,
|
||
sensitivityScope: {
|
||
source: "time_linked_candidate_scan_samples",
|
||
rangeStart: ready ? "05:16" : "04:50",
|
||
rangeEnd: ready ? "05:20" : "05:50",
|
||
sampleTimes: ready ? ["05:16", "05:20"] : ["04:50", "05:20", "05:50"],
|
||
},
|
||
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1"] }],
|
||
sensitiveLayers: [
|
||
{ layer: "D9", values: ["Aries", "Leo"], referenceIds: ["consult-d9"] },
|
||
{ layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["consult-d10"] },
|
||
],
|
||
supportedSensitiveLayers: ["D9", "D10"],
|
||
scoredHistoricalEvidence: ready ? [{
|
||
evidenceId: "00000000-0000-4000-8000-000000000709",
|
||
domain: "career",
|
||
candidateTime: "05:18",
|
||
score: 8,
|
||
ruleRefs: ["vim-career"],
|
||
}] : [],
|
||
suggestedDomains: [
|
||
{ domain: "relationship", layer: "D9", reason: "D9 在候选范围内呈现 Aries / Leo 差异,可用已发生的关系事件区分。" },
|
||
{ domain: "career", layer: "D10", reason: "D10 在候选范围内呈现 Taurus / Libra 差异,可用已发生的事业事件区分。" },
|
||
],
|
||
referenceIds: ["consult-d1", "consult-d9", "consult-d10"],
|
||
futureWindows: [{
|
||
label: "未来事业背景窗口",
|
||
startDate: "2028-03-01",
|
||
endDate: "2028-05-31",
|
||
scoreable: false,
|
||
}],
|
||
};
|
||
}
|
||
|
||
function validGenerator(
|
||
events: string[],
|
||
varyNarrative = false,
|
||
invalidNarrativeFromGeneration?: number,
|
||
prompts: string[] = [],
|
||
continueLatestEvent = false,
|
||
detailQuestion = "这份工作是正式工作,还是实习或兼职?",
|
||
mislabelLatestDetailAsNewEvent = false,
|
||
classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null,
|
||
freeNarrativeFromGeneration?: number,
|
||
) {
|
||
let generation = 0;
|
||
return {
|
||
modelId: "synthetic-rectification-model",
|
||
...(classifyEvidenceDomain ? {
|
||
async classifyEvidenceDomain(input: Readonly<{ text: string }>) {
|
||
return classifyEvidenceDomain(input.text);
|
||
},
|
||
} : {}),
|
||
async generate(prompt: string) {
|
||
generation += 1;
|
||
events.push("narrative");
|
||
prompts.push(prompt);
|
||
if (invalidNarrativeFromGeneration !== undefined
|
||
&& generation >= invalidNarrativeFromGeneration) {
|
||
return { text: "not a grounded narrative result" };
|
||
}
|
||
const request = JSON.parse(prompt) as {
|
||
phase: "first" | "intermediate" | "final";
|
||
conversation?: {
|
||
recentConversation?: Array<{ role: "assistant" | "user"; text: string }>;
|
||
latestUserText?: string;
|
||
};
|
||
conversationContext?: {
|
||
recentConversation?: Array<{ role: "assistant" | "user"; text: string }>;
|
||
latestEvidence?: Array<{ dateLabel: string; summary: string }>;
|
||
eventLedger?: Array<{
|
||
id: string;
|
||
summary: string;
|
||
active: boolean;
|
||
}>;
|
||
unresolvedEvidence?: Array<{
|
||
id: string;
|
||
summary: string;
|
||
dateLabel: string;
|
||
}>;
|
||
};
|
||
packet?: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||
candidate: ReturnType<typeof packet>["candidate"] & {
|
||
rangeStart: string;
|
||
rangeEnd: string;
|
||
};
|
||
};
|
||
};
|
||
const fallbackPacket = packet(false);
|
||
const value = request.packet ?? {
|
||
...fallbackPacket,
|
||
candidate: {
|
||
...fallbackPacket.candidate,
|
||
rangeStart: fallbackPacket.candidate.range.startTime,
|
||
rangeEnd: fallbackPacket.candidate.range.endTime,
|
||
},
|
||
};
|
||
const ordinaryConversation = request.phase === "intermediate" && request.conversation !== undefined;
|
||
const latestUserText = request.conversation?.latestUserText ?? request.conversationContext?.recentConversation?.at(-1)?.text ?? "";
|
||
const latest = request.conversationContext?.latestEvidence?.at(-1)
|
||
?? (latestUserText ? { dateLabel: "", summary: latestUserText } : undefined);
|
||
const latestActiveEvent = request.conversationContext?.eventLedger
|
||
?.filter((item) => item.active)
|
||
.at(-1);
|
||
const unresolved = request.conversationContext?.unresolvedEvidence?.at(-1);
|
||
const hasRelationshipEvidence = request.conversationContext?.eventLedger
|
||
?.some((item) => item.active && /关系|恋爱|分手|结婚|离婚|伴侣/.test(item.summary)) === true;
|
||
const domains = hasRelationshipEvidence ? ["career" as const] : ["relationship" as const];
|
||
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
|
||
const asksForLatestDetail = continueLatestEvent
|
||
&& request.phase === "intermediate"
|
||
&& (ordinaryConversation ? latest !== undefined : latestActiveEvent !== undefined);
|
||
const asksForMissingDate = ordinaryConversation
|
||
&& latest !== undefined
|
||
&& !/(?:19|20)\d{2}|\d{1,2}\s*月|(?:同年|当年|那年|来年)/u.test(latestUserText)
|
||
&& !/^(?:是|对|没错|确认|不是|不对)/u.test(latestUserText);
|
||
const freeNarrative = request.phase !== "final"
|
||
&& freeNarrativeFromGeneration !== undefined
|
||
&& generation >= freeNarrativeFromGeneration;
|
||
|
||
if (ordinaryConversation) {
|
||
const question = asksForMissingDate
|
||
? "这大约发生在哪一年、哪一月?"
|
||
: asksForLatestDetail ? detailQuestion : null;
|
||
const narrative = freeNarrative
|
||
? "我明白。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。"
|
||
: [
|
||
latest ? `你说的“${latest.summary}”,我理解了。` : "我在听。",
|
||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||
question ?? "",
|
||
].join("");
|
||
return { text: JSON.stringify({
|
||
narrative,
|
||
evidenceRequest: question ? {
|
||
domains,
|
||
datePrecision: "month_preferred",
|
||
prompt: question,
|
||
followUp: asksForMissingDate
|
||
? { kind: "event_date" }
|
||
: mislabelLatestDetailAsNewEvent
|
||
? { kind: "new_event", evidenceId: null }
|
||
: { kind: "event_detail" },
|
||
} : null,
|
||
}) };
|
||
}
|
||
|
||
const narrative = freeNarrative
|
||
? "我明白。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。"
|
||
: [
|
||
request.phase === "intermediate" && latest
|
||
? `你刚才说到${latest.dateLabel ? `${latest.dateLabel} · ` : ""}${latest.summary}。`
|
||
: `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||
request.phase === "final"
|
||
? "当前证据已形成候选总结。"
|
||
: unresolved?.dateLabel === "日期待补充"
|
||
? `我先把“${unresolved.summary}”这件事补完整:它大约发生在哪一年、哪一月?`
|
||
: asksForLatestDetail
|
||
? detailQuestion
|
||
: `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
|
||
].join("");
|
||
return { text: JSON.stringify({
|
||
narrative,
|
||
candidateStatus: value.candidate.status,
|
||
representativeTime: value.candidate.representativeTime,
|
||
rangeStart: value.candidate.rangeStart,
|
||
rangeEnd: value.candidate.rangeEnd,
|
||
useBoundary: value.useBoundary,
|
||
stableLayers: value.stableLayers.map((item) => typeof item === "string" ? item : item.layer),
|
||
sensitiveLayers: value.sensitiveLayers.map((item) => typeof item === "string" ? item : item.layer),
|
||
referenceIds: [],
|
||
domainReasons: [],
|
||
evidenceRequest: request.phase === "final" || freeNarrative ? null : {
|
||
domains,
|
||
datePrecision: "month_preferred",
|
||
prompt: unresolved?.dateLabel === "日期待补充"
|
||
? `“${unresolved.summary}”大约发生在哪一年、哪一月?`
|
||
: asksForLatestDetail
|
||
? detailQuestion
|
||
: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`,
|
||
followUp: unresolved?.dateLabel === "日期待补充"
|
||
? { kind: "event_date", evidenceId: unresolved.id }
|
||
: asksForLatestDetail
|
||
? mislabelLatestDetailAsNewEvent
|
||
? { kind: "new_event", evidenceId: null }
|
||
: { kind: "event_detail", evidenceId: latestActiveEvent?.id ?? null }
|
||
: { kind: "new_event", evidenceId: null },
|
||
},
|
||
}) };
|
||
},
|
||
};
|
||
}
|
||
|
||
type MutableCase = {
|
||
row: LoadedConversationalRectificationCase;
|
||
};
|
||
|
||
function harness(options: {
|
||
readonly createFailure?: Error;
|
||
readonly packetFailure?: Error;
|
||
readonly packetFailureFromBuild?: number;
|
||
readonly completeFailures?: number;
|
||
readonly releaseFailure?: boolean;
|
||
readonly readyAfterEvidenceCount?: number;
|
||
readonly packetForEvidenceCount?: (count: number) => RectificationTechnicalPacket;
|
||
readonly varyNarrative?: boolean;
|
||
readonly invalidNarrativeFromGeneration?: number;
|
||
readonly continueLatestEvent?: boolean;
|
||
readonly detailQuestion?: string;
|
||
readonly mislabelLatestDetailAsNewEvent?: boolean;
|
||
readonly classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null;
|
||
readonly freeNarrativeFromGeneration?: number;
|
||
} = {}) {
|
||
const events: string[] = [];
|
||
const narrativePrompts: string[] = [];
|
||
const mutations: string[] = [];
|
||
const cases = new Map<string, MutableCase>();
|
||
const receipts = new Map<string, {
|
||
input: unknown;
|
||
response: StoredConversationalRectificationCase;
|
||
actionKind?: "save_turn" | "pause" | "abandon" | "confirm";
|
||
expectedVersion?: number;
|
||
commandFingerprint?: string;
|
||
}>();
|
||
const packetEvidenceCounts: number[] = [];
|
||
const packetEvidenceIds: string[][] = [];
|
||
const packetPrivateCandidates: Array<Readonly<{
|
||
rangeStart?: string | null;
|
||
rangeEnd?: string | null;
|
||
resultId?: string | null;
|
||
}> | null> = [];
|
||
let packetBuilds = 0;
|
||
let reserveCount = 0;
|
||
let releaseCount = 0;
|
||
let remainingCompleteFailures = options.completeFailures ?? 0;
|
||
let billingState: LoadedConversationalRectificationCase["billingState"] = null;
|
||
|
||
function updateBillingState(next: LoadedConversationalRectificationCase["billingState"]) {
|
||
billingState = next;
|
||
for (const [caseId, value] of cases) {
|
||
cases.set(caseId, { row: { ...value.row, billingState: next } });
|
||
}
|
||
}
|
||
|
||
function stored(input: {
|
||
readonly userId: string;
|
||
readonly caseId: string;
|
||
readonly turn: ConversationalRectificationTurnInput;
|
||
readonly privateCandidate: LoadedConversationalRectificationCase["privateCandidate"];
|
||
readonly evidence?: LoadedConversationalRectificationCase["eventEvidence"];
|
||
readonly receipts?: LoadedConversationalRectificationCase["validationReceipts"];
|
||
readonly revisionOfCaseId?: string | null;
|
||
}): LoadedConversationalRectificationCase {
|
||
return {
|
||
caseId: input.caseId,
|
||
userId: input.userId,
|
||
status: input.turn.status === "completed" ? "completed" : input.turn.status,
|
||
turnVersion: input.turn.turnVersion,
|
||
revisionOfCaseId: input.revisionOfCaseId ?? priorCaseId,
|
||
importedFromCaseId: null,
|
||
baselineActiveTime: "04:58",
|
||
pendingConsultationQuestion: input.turn.pendingConsultationQuestion,
|
||
billingState,
|
||
latestTurn: conversationalRectificationTurnSchema.parse(input.turn),
|
||
declaredBirthInput,
|
||
privateCandidate: input.privateCandidate,
|
||
eventEvidence: input.evidence ?? [],
|
||
validationReceipts: input.receipts ?? [],
|
||
};
|
||
}
|
||
|
||
function replay(
|
||
actionId: string,
|
||
input: { readonly expectedVersion?: number; readonly commandFingerprint?: string },
|
||
make: () => LoadedConversationalRectificationCase,
|
||
actionKind?: "save_turn" | "pause" | "abandon" | "confirm",
|
||
) {
|
||
const prior = receipts.get(actionId);
|
||
if (prior) {
|
||
if (prior.actionKind && prior.commandFingerprint) {
|
||
assert.equal(actionKind, prior.actionKind);
|
||
assert.equal(input.expectedVersion, prior.expectedVersion);
|
||
assert.equal(input.commandFingerprint, prior.commandFingerprint);
|
||
} else {
|
||
assert.deepEqual(input, prior.input);
|
||
}
|
||
return prior.response;
|
||
}
|
||
const response = make();
|
||
receipts.set(actionId, {
|
||
input: structuredClone(input),
|
||
response,
|
||
actionKind,
|
||
expectedVersion: input.expectedVersion,
|
||
commandFingerprint: input.commandFingerprint,
|
||
});
|
||
return response;
|
||
}
|
||
|
||
const store: ConversationalRectificationServicePorts["store"] = {
|
||
async loadActionReceipt(input) {
|
||
const prior = receipts.get(input.actionId);
|
||
if (!prior?.actionKind) return null;
|
||
if (prior.actionKind !== input.actionKind
|
||
|| prior.expectedVersion !== input.expectedVersion
|
||
|| prior.commandFingerprint !== input.commandFingerprint) {
|
||
throw new ConversationalRectificationError("action_conflict");
|
||
}
|
||
return prior.response;
|
||
},
|
||
async loadCase(input) {
|
||
const value = input.caseId
|
||
? cases.get(input.caseId)?.row
|
||
: [...cases.values()].at(-1)?.row;
|
||
return value ?? null;
|
||
},
|
||
async createCaseWithFirstTurn(input) {
|
||
events.push("create");
|
||
mutations.push("create");
|
||
if (options.createFailure) throw options.createFailure;
|
||
return replay(input.actionId, input, () => {
|
||
const row = stored({
|
||
userId: input.userId,
|
||
caseId: input.caseId,
|
||
turn: input.firstTurn,
|
||
privateCandidate: input.privateCandidate,
|
||
receipts: [input.validationReceipt],
|
||
revisionOfCaseId: input.revisionOfCaseId,
|
||
});
|
||
cases.set(input.caseId, { row });
|
||
return row;
|
||
});
|
||
},
|
||
async saveTurn(input) {
|
||
mutations.push("saveTurn");
|
||
return replay(input.actionId, input, () => {
|
||
const current = cases.get(input.turn.caseId)?.row;
|
||
assert.ok(current);
|
||
if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||
const row = stored({
|
||
userId: input.userId,
|
||
caseId: input.turn.caseId,
|
||
turn: input.turn,
|
||
privateCandidate: input.privateCandidate,
|
||
evidence: [...current.eventEvidence, ...input.evidence],
|
||
receipts: [...current.validationReceipts, input.validationReceipt],
|
||
revisionOfCaseId: current.revisionOfCaseId,
|
||
});
|
||
cases.set(input.turn.caseId, { row });
|
||
return row;
|
||
}, "save_turn");
|
||
},
|
||
async pause(input) {
|
||
mutations.push("pause");
|
||
return replay(input.actionId, input, () => {
|
||
const current = cases.get(input.turn.caseId)?.row;
|
||
assert.ok(current);
|
||
if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||
cases.set(input.turn.caseId, { row });
|
||
return row;
|
||
}, "pause");
|
||
},
|
||
async abandon(input) {
|
||
mutations.push("abandon");
|
||
return replay(input.actionId, input, () => {
|
||
const current = cases.get(input.turn.caseId)?.row;
|
||
assert.ok(current);
|
||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||
cases.set(input.turn.caseId, { row });
|
||
return row;
|
||
}, "abandon");
|
||
},
|
||
async confirm(input) {
|
||
mutations.push("confirm");
|
||
return replay(input.actionId, input, () => {
|
||
const current = cases.get(input.turn.caseId)?.row;
|
||
assert.ok(current);
|
||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||
cases.set(input.turn.caseId, { row });
|
||
return row;
|
||
}, "confirm");
|
||
},
|
||
};
|
||
|
||
const ports: ConversationalRectificationServicePorts = {
|
||
get rectificationPriceCredits() {
|
||
events.push("price");
|
||
return 9;
|
||
},
|
||
store,
|
||
billing: {
|
||
async reserve(input) {
|
||
reserveCount += 1;
|
||
events.push(`reserve:${input.price}`);
|
||
updateBillingState("reserved");
|
||
return { success: true, credits: 91, billingState: "reserved" };
|
||
},
|
||
async complete() {
|
||
events.push("complete");
|
||
if (remainingCompleteFailures > 0) {
|
||
remainingCompleteFailures -= 1;
|
||
throw new Error("complete response unavailable");
|
||
}
|
||
updateBillingState("charged");
|
||
return { success: true, credits: 91, billingState: "charged" };
|
||
},
|
||
async release() {
|
||
releaseCount += 1;
|
||
events.push("release");
|
||
if (options.releaseFailure) throw new Error("release SQL detail");
|
||
updateBillingState("released");
|
||
return { success: true, credits: 100, billingState: "released" };
|
||
},
|
||
},
|
||
async loadDeclaredProfile() {
|
||
events.push("profile");
|
||
return { declaredBirthInput, revisionOfCaseId: priorCaseId };
|
||
},
|
||
async buildTechnicalPacket(input) {
|
||
packetBuilds += 1;
|
||
packetEvidenceCounts.push(input.evidence.length);
|
||
packetEvidenceIds.push(input.evidence.map((item) => item.id));
|
||
packetPrivateCandidates.push(input.privateCandidate
|
||
? {
|
||
rangeStart: input.privateCandidate.rangeStart,
|
||
rangeEnd: input.privateCandidate.rangeEnd,
|
||
resultId: input.privateCandidate.resultId,
|
||
}
|
||
: null);
|
||
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
|
||
if (options.packetFailure
|
||
&& (options.packetFailureFromBuild === undefined
|
||
|| packetBuilds >= options.packetFailureFromBuild)) throw options.packetFailure;
|
||
const selectedPacket = options.packetForEvidenceCount?.(input.evidence.length)
|
||
?? packet(input.evidence.length >= (options.readyAfterEvidenceCount ?? 1));
|
||
return {
|
||
packet: selectedPacket,
|
||
resultId: selectedPacket.candidate.status === "ready_for_confirmation" ? resultId : null,
|
||
};
|
||
},
|
||
narrativeGenerator: validGenerator(
|
||
events,
|
||
options.varyNarrative,
|
||
options.invalidNarrativeFromGeneration,
|
||
narrativePrompts,
|
||
options.continueLatestEvent,
|
||
options.detailQuestion,
|
||
options.mislabelLatestDetailAsNewEvent,
|
||
options.classifyEvidenceDomain,
|
||
options.freeNarrativeFromGeneration,
|
||
),
|
||
asOfDate: () => "2026-07-21",
|
||
};
|
||
|
||
return {
|
||
events,
|
||
narrativePrompts,
|
||
mutations,
|
||
packetEvidenceCounts,
|
||
packetEvidenceIds,
|
||
packetPrivateCandidates,
|
||
cases,
|
||
service: createConversationalRectificationService(ports),
|
||
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
|
||
forceLaterVersion(caseId: string, turnVersion: number) {
|
||
const current = cases.get(caseId)?.row;
|
||
assert.ok(current);
|
||
cases.set(caseId, {
|
||
row: {
|
||
...current,
|
||
status: "active",
|
||
turnVersion,
|
||
latestTurn: conversationalRectificationTurnSchema.parse({
|
||
...current.latestTurn,
|
||
status: "active",
|
||
turnVersion,
|
||
candidate: current.latestTurn.candidate.status === "confirmed"
|
||
? { ...current.latestTurn.candidate, status: "pending_validation" }
|
||
: current.latestTurn.candidate,
|
||
actions: ["answer", "pause", "abandon"],
|
||
}),
|
||
},
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
async function start(value: ReturnType<typeof harness>, pendingConsultationQuestion: string | null = "我的工作何时变化?") {
|
||
return value.service.start(userId, {
|
||
type: "start",
|
||
actionId: startActionId,
|
||
pendingConsultationQuestion,
|
||
});
|
||
}
|
||
|
||
test("start creates a deterministic opening without scanning or narrative generation", async () => {
|
||
const value = harness();
|
||
const turn = await start(value);
|
||
|
||
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "complete"]);
|
||
assert.equal(value.counts().packetBuilds, 0);
|
||
assert.equal(turn.caseId, startActionId);
|
||
assert.equal(turn.turnVersion, 0);
|
||
assert.equal(turn.pendingConsultationQuestion, "我的工作何时变化?");
|
||
assert.equal(turn.candidate.status, "pending_validation");
|
||
assert.equal(turn.candidate.representativeTime, "05:20");
|
||
assert.equal(turn.candidate.rangeStart, "04:50");
|
||
assert.equal(turn.candidate.rangeEnd, "05:50");
|
||
assert.match(turn.narrative, /当前先核对 04:50–05:50/);
|
||
assert.match(turn.narrative, /还不能把其中某一分钟当作已确认出生时间/);
|
||
assert.match(turn.narrative, /按自己的节奏/);
|
||
assert.match(turn.narrative, /一次说一件或连续说多件/);
|
||
assert.deepEqual(turn.technicalReceipt, {
|
||
calculationVersion: "rectification-opening-v1",
|
||
stableLayers: [],
|
||
sensitiveLayers: [],
|
||
candidateDifferenceRefs: [],
|
||
});
|
||
assert.equal(turn.evidenceRequest, null);
|
||
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
|
||
assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId);
|
||
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
|
||
assert.equal(value.cases.get(startActionId)?.row.validationReceipts[0]?.modelId, "deterministic-rectification-opening");
|
||
});
|
||
|
||
test("start rejects a client price before profile, billing, or calculation", async () => {
|
||
const value = harness();
|
||
await assert.rejects(value.service.start(userId, {
|
||
type: "start", actionId: startActionId, price: 0,
|
||
} as never), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "invalid_command");
|
||
assert.deepEqual(value.events, []);
|
||
});
|
||
|
||
test("every post-reservation failure releases exactly once and never leaks its cause", async () => {
|
||
const raw = "SQL model browser secret detail";
|
||
const value = harness({ createFailure: new Error(raw) });
|
||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "service_unavailable" && !error.message.includes(raw));
|
||
assert.equal(value.counts().releaseCount, 1);
|
||
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "release"]);
|
||
});
|
||
|
||
test("a start retry settles an existing reservation without reserving or computing again", async () => {
|
||
const value = harness({ completeFailures: 1, releaseFailure: true });
|
||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "billing_failed");
|
||
assert.equal(value.cases.get(startActionId)?.row.billingState, "reserved");
|
||
|
||
const replayed = await start(value);
|
||
|
||
assert.equal(replayed.status, "active");
|
||
assert.equal(value.cases.get(startActionId)?.row.billingState, "charged");
|
||
assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 });
|
||
assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]);
|
||
});
|
||
|
||
test("a duplicate start with the same declared birth input reuses the unfinished case", async () => {
|
||
const value = harness();
|
||
const first = await start(value, null);
|
||
const countsBeforeRetry = value.counts();
|
||
|
||
const replayed = await value.service.start(userId, {
|
||
type: "start",
|
||
actionId: secondStartActionId,
|
||
pendingConsultationQuestion: null,
|
||
});
|
||
|
||
assert.deepEqual(replayed, first);
|
||
assert.deepEqual(value.counts(), countsBeforeRetry);
|
||
assert.deepEqual(value.events, [
|
||
"profile", "price", "reserve:9", "create", "complete",
|
||
"profile", "price",
|
||
]);
|
||
});
|
||
|
||
test("a duplicate start with changed declared birth input remains a conflict", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
assert.ok(current);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
declaredBirthInput: {
|
||
...current.declaredBirthInput,
|
||
birthDate: "2000-01-02",
|
||
},
|
||
},
|
||
});
|
||
await assert.rejects(value.service.start(userId, {
|
||
type: "start",
|
||
actionId: secondStartActionId,
|
||
pendingConsultationQuestion: null,
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "action_conflict");
|
||
assert.equal(value.counts().reserveCount, 1);
|
||
assert.equal(value.counts().packetBuilds, 0);
|
||
});
|
||
|
||
test("a settlement failure releases its created case once and cannot replay as success", async () => {
|
||
const value = harness({ completeFailures: 1 });
|
||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "service_unavailable");
|
||
assert.equal(value.cases.get(startActionId)?.row.billingState, "released");
|
||
assert.deepEqual(value.events.slice(-3), ["create", "complete", "release"]);
|
||
|
||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "billing_failed");
|
||
assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 });
|
||
});
|
||
|
||
test("clear historical evidence is extracted, scored, narrated, recapped, and atomically saved", async () => {
|
||
const value = harness();
|
||
await start(value);
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚",
|
||
});
|
||
|
||
assert.equal(value.counts().packetBuilds, 1);
|
||
assert.equal(turn.status, "confirming");
|
||
assert.equal(turn.candidate.status, "ready_for_confirmation");
|
||
assert.equal(turn.turnVersion, 1);
|
||
assert.equal(turn.evidenceRecap.length, 4);
|
||
const saved = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(saved.length, 4);
|
||
assert.ok(saved.every((item) => item.rawText === "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚"));
|
||
assert.ok(saved.every((item) => item.scoreable === true));
|
||
assert.ok(value.events.includes("score-packet"));
|
||
});
|
||
|
||
test("evidence corrections are append-only while recap and scoring use only the effective lineage tip", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2019年7月开始第一份工作",
|
||
});
|
||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(firstId);
|
||
|
||
const corrected = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "更正:其实是2020年11月离职",
|
||
correctsEvidenceId: firstId,
|
||
});
|
||
const secondId = value.cases.get(startActionId)?.row.eventEvidence[1]?.id;
|
||
assert.ok(secondId);
|
||
assert.deepEqual(corrected.evidenceRecap, [{
|
||
id: secondId,
|
||
summary: "其实是离职",
|
||
dateLabel: "2020-11",
|
||
domain: "career",
|
||
isCorrection: true,
|
||
}]);
|
||
|
||
const twiceCorrected = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: thirdAnswerActionId,
|
||
turnVersion: 2,
|
||
answer: "更正:准确的是2021年2月入职",
|
||
correctsEvidenceId: secondId,
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 3, "the audit lineage must remain append-only");
|
||
assert.deepEqual(stored[0]?.correctsEvidenceIds ?? [], []);
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [firstId]);
|
||
assert.deepEqual(stored[2]?.correctsEvidenceIds, [secondId]);
|
||
assert.deepEqual(twiceCorrected.evidenceRecap, [{
|
||
id: stored[2]?.id,
|
||
summary: "准确的是入职",
|
||
dateLabel: "2021-02",
|
||
domain: "career",
|
||
isCorrection: true,
|
||
}]);
|
||
assert.deepEqual(value.packetEvidenceCounts, [1, 1, 1]);
|
||
});
|
||
|
||
test("uses Agent semantic classification when a single event falls through the deterministic keywords", async () => {
|
||
const classifiedTexts: string[] = [];
|
||
const value = harness({
|
||
readyAfterEvidenceCount: 99,
|
||
classifyEvidenceDomain(text) {
|
||
classifiedTexts.push(text);
|
||
return "career";
|
||
},
|
||
});
|
||
await start(value, null);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年4月去石油化工研究院实习做研究员",
|
||
});
|
||
|
||
assert.deepEqual(classifiedTexts, ["2020年4月去石油化工研究院实习做研究员"]);
|
||
const saved = value.cases.get(startActionId)?.row.eventEvidence.at(-1);
|
||
assert.equal(saved?.dateValue, "2020-04");
|
||
assert.equal(saved?.domain, "career");
|
||
assert.equal(saved?.scoreable, true);
|
||
});
|
||
|
||
test("repeated evidence remains auditable but identical date-domain-semantics score only once", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 2 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||
});
|
||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(firstId);
|
||
|
||
const repeated = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "2019年7月开始第一份工作",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2, "both user submissions remain in the audit ledger");
|
||
assert.equal(repeated.evidenceRecap.length, 2);
|
||
assert.deepEqual(value.packetEvidenceCounts, [1, 1]);
|
||
assert.deepEqual(value.packetEvidenceIds.at(-1), [firstId]);
|
||
assert.equal(repeated.status, "active");
|
||
assert.equal(repeated.actions.includes("confirm"), false);
|
||
});
|
||
|
||
test("an unclear correction immediately retires the wrong fact and stays retired after later turns", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||
});
|
||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(wrongId);
|
||
|
||
const clarification = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "更正:具体年月记不清", correctsEvidenceId: wrongId,
|
||
});
|
||
const unclear = value.cases.get(startActionId)?.row.eventEvidence[1];
|
||
assert.equal(unclear?.extractionStatus, "needs_clarification");
|
||
assert.equal(unclear?.scoreable, false);
|
||
assert.deepEqual(unclear?.correctsEvidenceIds, [wrongId]);
|
||
assert.deepEqual(clarification.evidenceRecap, [{
|
||
id: unclear?.id,
|
||
summary: "具体年月记不清",
|
||
dateLabel: "日期待补充",
|
||
domain: "other",
|
||
isCorrection: true,
|
||
}]);
|
||
assert.equal(clarification.status, "active");
|
||
assert.equal(clarification.candidate.status, "pending_validation");
|
||
assert.equal(clarification.actions.includes("confirm"), false);
|
||
assert.equal(value.cases.get(startActionId)?.row.privateCandidate.resultId, null);
|
||
assert.deepEqual(
|
||
value.cases.get(startActionId)?.row.privateCandidate.scoredHistoricalEvidence ?? [],
|
||
[],
|
||
);
|
||
assert.deepEqual(value.packetEvidenceCounts, [1, 0]);
|
||
|
||
const later = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: thirdAnswerActionId,
|
||
turnVersion: 2, answer: "2022年3月搬家",
|
||
});
|
||
assert.deepEqual(value.packetEvidenceCounts, [1, 0, 1]);
|
||
assert.equal(later.evidenceRecap.some((item) => item.id === wrongId), false);
|
||
assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true);
|
||
});
|
||
|
||
test("every non-confirmable correction rescans the declared range and withdraws the old candidate", async () => {
|
||
const scenarios = [
|
||
{ name: "unclear", answer: "更正:具体年月记不清" },
|
||
{ name: "future", answer: "更正:2099年3月开始新工作" },
|
||
{ name: "direction change", answer: "更正:这些都不符合" },
|
||
] as const;
|
||
|
||
for (const scenario of scenarios) {
|
||
const value = harness();
|
||
await start(value, null);
|
||
const prior = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚",
|
||
});
|
||
assert.equal(prior.status, "confirming", scenario.name);
|
||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(wrongId, scenario.name);
|
||
const buildsBeforeCorrection = value.counts().packetBuilds;
|
||
|
||
const corrected = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: scenario.answer,
|
||
correctsEvidenceId: wrongId,
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
const replacement = stored?.eventEvidence.at(-1);
|
||
assert.ok(stored && replacement, scenario.name);
|
||
assert.equal(value.counts().packetBuilds, buildsBeforeCorrection + 1, scenario.name);
|
||
assert.equal(value.packetPrivateCandidates.at(-1), null, scenario.name);
|
||
assert.equal(value.packetEvidenceIds.at(-1)?.includes(wrongId), false, scenario.name);
|
||
assert.equal(corrected.status, "active", scenario.name);
|
||
assert.equal(corrected.candidate.status, "pending_validation", scenario.name);
|
||
assert.equal(corrected.actions.includes("confirm"), false, scenario.name);
|
||
assert.equal(stored.privateCandidate.resultId ?? null, null, scenario.name);
|
||
assert.equal(stored.privateCandidate.workingState?.phase, "collecting_evidence", scenario.name);
|
||
assert.equal(corrected.evidenceRecap.some((item) => item.id === wrongId), false, scenario.name);
|
||
assert.equal(corrected.evidenceRecap.some((item) => item.id === replacement.id), true, scenario.name);
|
||
assert.equal(value.counts().reserveCount, 1, scenario.name);
|
||
|
||
const expectedPacketEvidenceIds = stored.eventEvidence
|
||
.filter((item) => item.id !== wrongId
|
||
&& item.scoreable === true
|
||
&& item.extractionStatus !== "needs_clarification")
|
||
.map((item) => item.id);
|
||
assert.deepEqual(value.packetEvidenceIds.at(-1), expectedPacketEvidenceIds);
|
||
}
|
||
});
|
||
|
||
test("a narrative fallback cannot discard a confirmation candidate produced by a valid correction", async () => {
|
||
const value = harness({ invalidNarrativeFromGeneration: 2 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚",
|
||
});
|
||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(wrongId);
|
||
|
||
const corrected = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "更正:其实是2020年11月离职", correctsEvidenceId: wrongId,
|
||
});
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
assert.ok(stored);
|
||
assert.equal(stored.validationReceipts.at(-1)?.fallbackUsed, true);
|
||
assert.equal(corrected.status, "confirming");
|
||
assert.equal(corrected.candidate.status, "ready_for_confirmation");
|
||
assert.equal(stored.privateCandidate.resultId, resultId);
|
||
assert.equal(stored.privateCandidate.workingState?.phase, "ready");
|
||
|
||
const confirmed = await value.service.confirm(userId, {
|
||
type: "confirm", caseId: startActionId, actionId: confirmActionId,
|
||
turnVersion: corrected.turnVersion, time: "05:18",
|
||
});
|
||
assert.equal(confirmed.status, "completed");
|
||
assert.equal(value.mutations.at(-1), "confirm");
|
||
});
|
||
|
||
test("a clear one-to-one correction can form a new confirmation candidate only after a declared-range rescan", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚",
|
||
});
|
||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(wrongId);
|
||
|
||
const corrected = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "更正:其实是2020年11月离职",
|
||
correctsEvidenceId: wrongId,
|
||
});
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
const replacementId = stored?.eventEvidence.at(-1)?.id;
|
||
assert.ok(stored && replacementId);
|
||
|
||
assert.equal(value.packetPrivateCandidates.at(-1), null);
|
||
assert.deepEqual(
|
||
value.packetEvidenceIds.at(-1),
|
||
stored.eventEvidence
|
||
.filter((item) => item.id !== wrongId
|
||
&& item.scoreable === true
|
||
&& item.extractionStatus !== "needs_clarification")
|
||
.map((item) => item.id),
|
||
);
|
||
assert.equal(corrected.status, "confirming");
|
||
assert.equal(corrected.candidate.status, "ready_for_confirmation");
|
||
assert.equal(corrected.actions.includes("confirm"), true);
|
||
assert.equal(stored.privateCandidate.resultId, resultId);
|
||
});
|
||
|
||
test("a targeted correction must extract exactly one replacement before scoring or persistence", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||
});
|
||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(wrongId);
|
||
const before = {
|
||
builds: value.counts().packetBuilds,
|
||
mutations: [...value.mutations],
|
||
version: value.cases.get(startActionId)?.row.turnVersion,
|
||
evidenceCount: value.cases.get(startActionId)?.row.eventEvidence.length,
|
||
};
|
||
|
||
await assert.rejects(value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "更正:2020年11月离职,并在2021年2月入职",
|
||
correctsEvidenceId: wrongId,
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "invalid_command");
|
||
|
||
assert.equal(value.counts().packetBuilds, before.builds);
|
||
assert.deepEqual(value.mutations, before.mutations);
|
||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, before.version);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, before.evidenceCount);
|
||
});
|
||
|
||
test("ordinary new evidence continues incrementally from the current candidate range", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||
});
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "2020年11月搬家",
|
||
});
|
||
|
||
assert.deepEqual(value.packetPrivateCandidates, [
|
||
{ rangeStart: "04:50", rangeEnd: "05:50", resultId: null },
|
||
{ rangeStart: "05:16", rangeEnd: "05:20", resultId: null },
|
||
]);
|
||
});
|
||
|
||
test("missing or already-retired correction targets fail closed without advancing or persisting", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
const before = [...value.mutations];
|
||
await assert.rejects(value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "更正:2020年11月离职",
|
||
correctsEvidenceId: "00000000-0000-4000-8000-000000000799",
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "action_conflict");
|
||
assert.deepEqual(value.mutations, before);
|
||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, 0);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 0);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||
});
|
||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(firstId);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: thirdAnswerActionId,
|
||
turnVersion: 1, answer: "更正:2020年11月离职", correctsEvidenceId: firstId,
|
||
});
|
||
const versionBeforeConflict = value.cases.get(startActionId)?.row.turnVersion;
|
||
const evidenceBeforeConflict = value.cases.get(startActionId)?.row.eventEvidence.length;
|
||
await assert.rejects(value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: fourthAnswerActionId,
|
||
turnVersion: 2, answer: "再次覆盖旧错误:2021年2月入职", correctsEvidenceId: firstId,
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "action_conflict");
|
||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, versionBeforeConflict);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, evidenceBeforeConflict);
|
||
});
|
||
|
||
test("generic date uncertainty does not suppress clear historical evidence", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2021年7月毕业,具体日期不确定",
|
||
});
|
||
|
||
assert.equal(turn.status, "active");
|
||
assert.equal(turn.candidate.status, "pending_validation");
|
||
assert.equal(value.counts().packetBuilds, 1);
|
||
assert.ok(value.events.includes("score-packet"));
|
||
assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? [])
|
||
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
|
||
});
|
||
|
||
test("a concrete event without a date is acknowledged and a date-only follow-up completes it", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
|
||
const clarification = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "我离开家去北京开始工作",
|
||
});
|
||
|
||
assert.match(clarification.narrative, /离开家去北京开始工作/);
|
||
assert.match(clarification.narrative, /哪一年、哪一月/);
|
||
assert.doesNotMatch(
|
||
clarification.narrative,
|
||
/你提到.*具体内容我已经记下了|只记得年份也可以/,
|
||
);
|
||
assert.equal(clarification.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "2023年3月",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2, "the incomplete fact and its completion remain auditable");
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [stored[0]?.id]);
|
||
assert.equal(stored[1]?.eventSummary, "我离开家去北京开始工作");
|
||
assert.equal(stored[1]?.dateValue, "2023-03");
|
||
assert.equal(stored[1]?.scoreable, true);
|
||
assert.deepEqual(completed.evidenceRecap.map((item) => ({
|
||
summary: item.summary,
|
||
dateLabel: item.dateLabel,
|
||
})), [{ summary: "我离开家去北京开始工作", dateLabel: "2023-03" }]);
|
||
assert.match(completed.narrative, /2023年3月/);
|
||
assert.doesNotMatch(completed.narrative, /记下了|已记录|校时价值|候选时间/);
|
||
});
|
||
|
||
test("a descriptive date follow-up completes the targeted event instead of becoming a new event", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "我彻底离开了学校",
|
||
});
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "2023年1月我正式办完了手续",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2, "the original answer and its completion remain auditable");
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [stored[0]?.id]);
|
||
assert.equal(stored[1]?.dateValue, "2023-01");
|
||
assert.equal(stored[1]?.eventSummary, "我彻底离开了学校");
|
||
assert.equal(stored[1]?.rawText, "我彻底离开了学校\n补充:2023年1月我正式办完了手续");
|
||
assert.equal(completed.evidenceRecap.length, 1);
|
||
assert.doesNotMatch(completed.narrative, /还差时间定位/);
|
||
});
|
||
|
||
test("an independent future event does not get swallowed as the date of an unresolved historical event", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "后来工作压力很大",
|
||
});
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "2027年计划换工作",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2);
|
||
assert.equal(stored[0]?.dateValue, null);
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, []);
|
||
assert.equal(stored[1]?.dateValue, "2027");
|
||
assert.equal(stored[1]?.scoreable, false);
|
||
assert.deepEqual(completed.evidenceRecap.map((item) => item.dateLabel), [
|
||
"日期待补充",
|
||
"2027(未来,仅作背景)",
|
||
]);
|
||
});
|
||
|
||
test("a next-year month answer resolves from the event being discussed", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true });
|
||
await start(value, null);
|
||
|
||
const first = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2022年12月正式退学",
|
||
});
|
||
assert.equal(first.evidenceRequest?.followUp?.kind, "event_detail");
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "来年 1 月份我彻底离开的学校",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.deepEqual(stored.map((item) => item.dateValue), ["2022-12", "2023-01"]);
|
||
assert.equal(stored[1]?.rawText, "来年 1 月份我彻底离开的学校");
|
||
assert.equal(stored[1]?.scoreable, true);
|
||
assert.doesNotMatch(completed.narrative, /还差时间定位|大约是哪一年、哪一月/);
|
||
});
|
||
|
||
test("a bare month-day answer refines the targeted month without another confirmation", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true });
|
||
await start(value, null);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2026年7月我们三个人决定一起开公司",
|
||
});
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
assert.ok(current.latestTurn.evidenceRequest);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
latestTurn: {
|
||
...current.latestTurn,
|
||
evidenceRequest: {
|
||
...current.latestTurn.evidenceRequest,
|
||
followUp: { kind: "event_date", evidenceId: target.id },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "7 月 10 号",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2, "the month event and day refinement remain auditable");
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [target.id]);
|
||
assert.equal(stored[1]?.dateValue, "2026-07-10");
|
||
assert.equal(stored[1]?.eventSummary, "我们三个人决定一起开公司");
|
||
assert.equal(stored[1]?.rawText, "2026年7月我们三个人决定一起开公司\n补充:7 月 10 号");
|
||
assert.equal(completed.evidenceRecap.at(-1)?.dateLabel, "2026-07-10");
|
||
assert.doesNotMatch(completed.narrative, /是指.*7 月 10|哪一天|哪一年、哪一月/);
|
||
});
|
||
|
||
async function preparedDateConfirmation() {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年我去石油化工研究院实习,后来主动辞职",
|
||
});
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
latestTurn: {
|
||
...current.latestTurn,
|
||
narrative: "这个10月是2020年10月吗?",
|
||
evidenceRequest: {
|
||
domains: ["career"],
|
||
datePrecision: "month_preferred",
|
||
freeTextAllowed: true,
|
||
prompt: "这个10月是2020年10月吗?",
|
||
followUp: {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "yes_no",
|
||
proposedDate: { value: "2020-10", precision: "month" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
return { value, target, initialEvidenceCount: current.eventEvidence.length };
|
||
}
|
||
|
||
test("all supported affirmative tokens confirm the structured proposed date", async () => {
|
||
for (const answer of ["对", "没错", "嗯", "确认"]) {
|
||
const { value, target, initialEvidenceCount } = await preparedDateConfirmation();
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer,
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, initialEvidenceCount + 1, answer);
|
||
assert.equal(stored.at(-1)?.dateValue, "2020-10", answer);
|
||
assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id], answer);
|
||
}
|
||
});
|
||
|
||
test("the alternate negative token rejects the proposal without creating evidence", async () => {
|
||
const { value, target, initialEvidenceCount } = await preparedDateConfirmation();
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "不对",
|
||
});
|
||
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, initialEvidenceCount);
|
||
assert.deepEqual(completed.evidenceRequest?.followUp, {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "free_text",
|
||
proposedDate: null,
|
||
});
|
||
});
|
||
|
||
test("an affirmative reply confirms the date proposed by the previous Agent turn", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年我去石油化工研究院实习,后来主动辞职",
|
||
});
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
const initialEvidenceCount = current?.eventEvidence.length ?? 0;
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
latestTurn: {
|
||
...current.latestTurn,
|
||
narrative: "你说实习到10月份然后辞职,这个10月是2020年10月吗?",
|
||
evidenceRequest: {
|
||
domains: ["career"],
|
||
datePrecision: "month_preferred",
|
||
freeTextAllowed: true,
|
||
prompt: "这个10月是2020年10月吗?",
|
||
followUp: {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "yes_no",
|
||
proposedDate: { value: "2020-10", precision: "month" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "是的",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, initialEvidenceCount + 1, "confirmation is an auditable correction, not a standalone event");
|
||
assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id]);
|
||
assert.equal(stored.at(-1)?.dateValue, "2020-10");
|
||
assert.doesNotMatch(stored.at(-1)?.eventSummary ?? "", /^是的$/);
|
||
assert.doesNotMatch(completed.narrative, /这个10月是2020年10月吗/);
|
||
assert.notEqual(completed.evidenceRequest?.followUp?.evidenceId, target.id);
|
||
|
||
const prompt = JSON.parse(value.narrativePrompts.at(-1) ?? "{}") as {
|
||
conversation?: {
|
||
recentConversation?: Array<{ role: string; text: string }>;
|
||
};
|
||
conversationContext?: unknown;
|
||
};
|
||
assert.deepEqual(prompt.conversation?.recentConversation?.slice(-2), [
|
||
{ role: "assistant", text: "你说实习到10月份然后辞职,这个10月是2020年10月吗?" },
|
||
{ role: "user", text: "是的" },
|
||
]);
|
||
assert.equal(prompt.conversationContext, undefined);
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "是的",
|
||
});
|
||
assert.equal(
|
||
value.cases.get(startActionId)?.row.eventEvidence.length,
|
||
initialEvidenceCount + 1,
|
||
"replaying the same action does not duplicate the correction",
|
||
);
|
||
});
|
||
|
||
test("a rejected proposed date stays on the same event and switches to free text", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "我去石油化工研究院实习到10月,后来主动辞职",
|
||
});
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
latestTurn: {
|
||
...current.latestTurn,
|
||
narrative: "这个10月是2020年10月吗?",
|
||
evidenceRequest: {
|
||
domains: ["career"],
|
||
datePrecision: "month_preferred",
|
||
freeTextAllowed: true,
|
||
prompt: "这个10月是2020年10月吗?",
|
||
followUp: {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "yes_no",
|
||
proposedDate: { value: "2020-10", precision: "month" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const resumed = await value.service.resume(userId, {
|
||
type: "resume",
|
||
caseId: startActionId,
|
||
actionId: resumeActionId,
|
||
turnVersion: 1,
|
||
});
|
||
assert.equal(resumed.evidenceRequest?.followUp?.proposedDate?.value, "2020-10");
|
||
|
||
const beforeCount = current.eventEvidence.length;
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "不是",
|
||
});
|
||
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, beforeCount);
|
||
assert.deepEqual(completed.evidenceRequest?.followUp, {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "free_text",
|
||
proposedDate: null,
|
||
});
|
||
assert.doesNotMatch(completed.narrative, /这个10月是2020年10月吗/);
|
||
});
|
||
|
||
test("an explicit correction after rejecting a proposed date uses the supplied date", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "我去石油化工研究院实习到10月,后来主动辞职",
|
||
});
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
value.cases.set(startActionId, {
|
||
row: {
|
||
...current,
|
||
latestTurn: {
|
||
...current.latestTurn,
|
||
narrative: "这个10月是2020年10月吗?",
|
||
evidenceRequest: {
|
||
domains: ["career"],
|
||
datePrecision: "month_preferred",
|
||
freeTextAllowed: true,
|
||
prompt: "这个10月是2020年10月吗?",
|
||
followUp: {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "yes_no",
|
||
proposedDate: { value: "2020-10", precision: "month" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "不是,是2021年10月",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.at(-1)?.dateValue, "2021-10");
|
||
assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id]);
|
||
assert.doesNotMatch(stored.at(-1)?.eventSummary ?? "", /^不是/);
|
||
});
|
||
|
||
test("an authored event-detail follow-up survives progress decoration and keeps the prior date", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true });
|
||
await start(value, null);
|
||
|
||
const firstAnswer = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2017年5月参加工作",
|
||
});
|
||
|
||
const current = value.cases.get(startActionId)?.row;
|
||
const target = current?.eventEvidence.at(-1);
|
||
assert.ok(current);
|
||
assert.ok(target);
|
||
assert.match(firstAnswer.narrative, /正式工作,还是实习或兼职/);
|
||
assert.deepEqual(
|
||
value.cases.get(startActionId)?.row.latestTurn.evidenceRequest?.followUp,
|
||
{ kind: "event_detail", evidenceId: target.id },
|
||
);
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "正式工作",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2, "the original event and merged correction remain auditable");
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [target.id]);
|
||
assert.equal(stored[1]?.dateValue, "2017-05");
|
||
assert.equal(stored[1]?.eventSummary, "参加工作;正式工作");
|
||
assert.equal(stored[1]?.scoreable, true);
|
||
assert.equal(completed.evidenceRecap.at(-1)?.dateLabel, "2017-05");
|
||
assert.doesNotMatch(completed.narrative, /大致是什么年月|只记得年份/);
|
||
});
|
||
|
||
test("an unscored event detail mislabeled as new_event is not heuristically merged as a correction", async () => {
|
||
const value = harness({
|
||
readyAfterEvidenceCount: 99,
|
||
continueLatestEvent: true,
|
||
detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?",
|
||
mislabelLatestDetailAsNewEvent: true,
|
||
});
|
||
await start(value, null);
|
||
|
||
const firstAnswer = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "1972年12月因为学业压力正式退学",
|
||
});
|
||
|
||
const target = value.cases.get(startActionId)?.row.eventEvidence.at(-1);
|
||
assert.ok(target);
|
||
assert.match(firstAnswer.narrative, /压力具体体现在哪些方面/);
|
||
assert.deepEqual(
|
||
value.cases.get(startActionId)?.row.latestTurn.evidenceRequest?.followUp,
|
||
{ kind: "new_event", evidenceId: null },
|
||
);
|
||
|
||
const completed = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "经济负担导致无法继续",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2);
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, []);
|
||
assert.equal(stored[1]?.dateValue, null);
|
||
assert.match(stored[1]?.eventSummary ?? "", /经济负担导致无法继续/);
|
||
assert.equal(completed.evidenceRecap.length, 2);
|
||
assert.equal(completed.evidenceRecap.some((item) => item.id === target.id), true);
|
||
assert.doesNotMatch(completed.narrative, /这件事很有用|还差时间定位|大约是哪一年、哪一月/);
|
||
});
|
||
|
||
test("an undated independent event is not swallowed by a broad detail question mislabeled as new_event", async () => {
|
||
const value = harness({
|
||
readyAfterEvidenceCount: 99,
|
||
continueLatestEvent: true,
|
||
detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?",
|
||
mislabelLatestDetailAsNewEvent: true,
|
||
});
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2017年5月硕士毕业",
|
||
});
|
||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(firstId);
|
||
|
||
const next = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "后来我搬去上海开始工作",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2);
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, []);
|
||
assert.equal(stored[1]?.dateValue, null);
|
||
assert.match(stored[1]?.eventSummary ?? "", /搬去上海开始工作/);
|
||
assert.equal(next.evidenceRecap.some((item) => item.id === firstId), true);
|
||
assert.equal(next.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||
});
|
||
|
||
test("a dated independent event is not swallowed by a broad detail question mislabeled as new_event", async () => {
|
||
const value = harness({
|
||
readyAfterEvidenceCount: 99,
|
||
continueLatestEvent: true,
|
||
detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?",
|
||
mislabelLatestDetailAsNewEvent: true,
|
||
});
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2017年5月硕士毕业",
|
||
});
|
||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||
assert.ok(firstId);
|
||
|
||
const next = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "2017年7月入职第一家公司",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 2);
|
||
assert.deepEqual(stored[1]?.correctsEvidenceIds, []);
|
||
assert.equal(stored[1]?.dateValue, "2017-07");
|
||
assert.match(stored[1]?.eventSummary ?? "", /入职第一家公司/);
|
||
assert.equal(next.evidenceRecap.length, 2);
|
||
assert.equal(next.evidenceRecap.some((item) => item.id === firstId), true);
|
||
});
|
||
|
||
test("a completed event does not force the Agent into the next scoring domain", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年5月结婚",
|
||
});
|
||
|
||
assert.equal(turn.evidenceRequest, null);
|
||
assert.doesNotMatch(turn.narrative, /下一步|事业|重要关系|校时价值|候选时间/);
|
||
});
|
||
|
||
test("a rejected intermediate narrative falls back without discarding the event", async () => {
|
||
const value = harness({ invalidNarrativeFromGeneration: 1 });
|
||
await start(value, null);
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2021年7月开始第一份长期工作",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
assert.equal(turn.turnVersion, 1);
|
||
assert.match(turn.narrative, /这件事之后.*发生了什么/);
|
||
assert.equal(stored?.privateCandidate.resultId, null);
|
||
assert.equal(stored?.eventEvidence.length, 1);
|
||
assert.equal(stored?.validationReceipts.length, 2);
|
||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 1);
|
||
});
|
||
|
||
test("one through three supported events save and narrate before the fourth accumulated event ranks", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 4 });
|
||
await start(value, null);
|
||
const answers = [
|
||
[answerActionId, "2019年7月毕业"],
|
||
[secondAnswerActionId, "2020年8月搬家"],
|
||
[thirdAnswerActionId, "2021年9月换工作"],
|
||
[fourthAnswerActionId, "2022年10月结婚"],
|
||
] as const;
|
||
|
||
for (const [index, [receivedActionId, answer]] of answers.entries()) {
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: receivedActionId,
|
||
turnVersion: index,
|
||
answer,
|
||
});
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
assert.equal(stored?.eventEvidence.length, index + 1);
|
||
assert.equal(turn.evidenceRecap.length, index + 1);
|
||
assert.equal(turn.status, index < 3 ? "active" : "confirming");
|
||
assert.ok(turn.narrative.length > 0);
|
||
assert.doesNotMatch(turn.narrative, /当前累计|本轮已纳入|本轮区分重点|下一步:/);
|
||
}
|
||
|
||
assert.equal(value.counts().packetBuilds, 4);
|
||
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
|
||
});
|
||
|
||
test("intermediate narrative receives conversation text without the private event ledger", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, null);
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年9月底主动离开研究单位",
|
||
});
|
||
await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: secondAnswerActionId,
|
||
turnVersion: 1,
|
||
answer: "2023年4月进入下一家公司",
|
||
});
|
||
|
||
const prompt = value.narrativePrompts.at(-1) ?? "";
|
||
assert.match(prompt, /2020年9月底主动离开研究单位/);
|
||
assert.match(prompt, /2023年4月进入下一家公司/);
|
||
assert.doesNotMatch(prompt, /eventLedger|unresolvedEvidence|expertWorkflow|candidateWeights/);
|
||
});
|
||
|
||
test("a plateaued non-confirmable conversational case returns a bounded candidate after current domains are covered", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||
await start(value, "请继续回答原来的事业问题");
|
||
const answers = [
|
||
[answerActionId, "2019年7月毕业"],
|
||
[secondAnswerActionId, "2020年8月搬家"],
|
||
[thirdAnswerActionId, "2021年9月换工作"],
|
||
[fourthAnswerActionId, "2022年10月结婚"],
|
||
] as const;
|
||
let latest = value.cases.get(startActionId)?.row.latestTurn;
|
||
|
||
for (const [index, [receivedActionId, answer]] of answers.entries()) {
|
||
latest = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: receivedActionId,
|
||
turnVersion: index,
|
||
answer,
|
||
});
|
||
if (index === 2) {
|
||
assert.equal(latest.status, "active");
|
||
assert.equal(latest.evidenceRequest, null);
|
||
}
|
||
}
|
||
|
||
assert.equal(latest?.status, "completed");
|
||
assert.equal(latest?.candidate.status, "pending_validation");
|
||
assert.deepEqual(latest?.actions, ["continue_original_question"]);
|
||
assert.equal(latest?.evidenceRequest, null);
|
||
assert.equal(value.cases.get(startActionId)?.row.status, "completed");
|
||
assert.equal(value.cases.get(startActionId)?.row.privateCandidate.representativeTime, "05:20");
|
||
assert.equal(value.cases.get(startActionId)?.row.privateCandidate.resultId, null);
|
||
});
|
||
|
||
test("an unanswered suggested domain keeps a plateaued candidate conversational", async () => {
|
||
const value = harness({
|
||
packetForEvidenceCount() {
|
||
const pending = packet(false);
|
||
return {
|
||
...pending,
|
||
suggestedDomains: [{
|
||
domain: "finance",
|
||
layer: "D2",
|
||
reason: "D2 仍需要一条已发生的财务事件区分。",
|
||
}],
|
||
};
|
||
},
|
||
});
|
||
await start(value, null);
|
||
const answers = [
|
||
[answerActionId, "2019年7月毕业"],
|
||
[secondAnswerActionId, "2020年8月搬家"],
|
||
[thirdAnswerActionId, "2021年9月换工作"],
|
||
[fourthAnswerActionId, "2022年10月结婚"],
|
||
] as const;
|
||
let latest = value.cases.get(startActionId)?.row.latestTurn;
|
||
|
||
for (const [index, [receivedActionId, answer]] of answers.entries()) {
|
||
latest = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: receivedActionId,
|
||
turnVersion: index,
|
||
answer,
|
||
});
|
||
}
|
||
|
||
assert.equal(latest?.status, "active");
|
||
assert.equal(latest?.evidenceRequest, null);
|
||
assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]);
|
||
});
|
||
|
||
test("answer persists after pause when the packet has no grounded follow-up domain", async () => {
|
||
const value = harness({
|
||
packetForEvidenceCount() {
|
||
return { ...packet(false), suggestedDomains: [] };
|
||
},
|
||
});
|
||
const started = await start(value, null);
|
||
const paused = await value.service.pause(userId, {
|
||
type: "pause",
|
||
caseId: startActionId,
|
||
actionId: pauseActionId,
|
||
turnVersion: started.turnVersion,
|
||
});
|
||
await value.service.resume(userId, {
|
||
type: "resume",
|
||
caseId: startActionId,
|
||
actionId: resumeActionId,
|
||
turnVersion: paused.turnVersion,
|
||
});
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: paused.turnVersion,
|
||
answer: "2020年4月进入研究院实习,10月主动辞职",
|
||
});
|
||
|
||
assert.equal(turn.status, "active");
|
||
assert.equal(turn.evidenceRequest, null);
|
||
assert.match(turn.narrative, /2020年4月进入研究院实习/);
|
||
assert.doesNotMatch(turn.narrative, /记下了|已记录|校时价值|候选时间/);
|
||
});
|
||
|
||
test("system-only blockers return a bounded result without waiting for another plateau", async () => {
|
||
const value = harness({
|
||
packetForEvidenceCount(count) {
|
||
const pending = packet(false);
|
||
if (count === 0) return pending;
|
||
const range = count >= 4
|
||
? { startTime: "05:00", endTime: "05:40" }
|
||
: pending.candidate.range;
|
||
return {
|
||
...pending,
|
||
candidate: { ...pending.candidate, range },
|
||
sensitivityScope: {
|
||
...pending.sensitivityScope,
|
||
rangeStart: range.startTime,
|
||
rangeEnd: range.endTime,
|
||
},
|
||
suggestedDomains: [],
|
||
expertWorkflow: {
|
||
boundary: "not_auto_rectified",
|
||
candidateWindows: [{ ...range, status: "pending_validation" }],
|
||
techniqueAuditTable: [],
|
||
confirmationAllowed: false,
|
||
hardBlockers: ["required_layers_incomplete"],
|
||
gates: {},
|
||
},
|
||
};
|
||
},
|
||
});
|
||
await start(value, null);
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2019年7月毕业,2020年8月搬家,2021年9月换工作,2022年10月结婚",
|
||
});
|
||
|
||
assert.equal(turn.status, "completed");
|
||
assert.equal(turn.candidate.status, "pending_validation");
|
||
assert.equal(turn.evidenceRequest, null);
|
||
assert.deepEqual(turn.actions, []);
|
||
assert.equal(turn.candidate.rangeStart, "05:00");
|
||
assert.equal(turn.candidate.rangeEnd, "05:40");
|
||
});
|
||
|
||
test("family evidence remains stored and public without changing its domain", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 3 });
|
||
await start(value, null);
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2020年7月父亲生病",
|
||
});
|
||
|
||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||
assert.equal(stored.length, 1);
|
||
assert.equal(stored[0]?.domain, "family");
|
||
assert.match(stored[0]?.eventSummary ?? "", /父亲/);
|
||
assert.deepEqual(turn.evidenceRecap, [{
|
||
id: stored[0]?.id,
|
||
summary: stored[0]?.eventSummary,
|
||
dateLabel: "2020-07",
|
||
domain: "family",
|
||
}]);
|
||
assert.deepEqual(value.packetEvidenceCounts, [0]);
|
||
assert.equal(turn.status, "active");
|
||
});
|
||
|
||
test("three valid events plus pre-birth evidence wait until a later valid fourth event confirms", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 4 });
|
||
await start(value, null);
|
||
const answers = [
|
||
[answerActionId, "2019年7月毕业"],
|
||
[secondAnswerActionId, "2020年8月搬家"],
|
||
[thirdAnswerActionId, "1999年12月开始工作"],
|
||
[fourthAnswerActionId, "2021年9月换工作"],
|
||
[fifthAnswerActionId, "2022年10月结婚"],
|
||
] as const;
|
||
|
||
let latest = value.cases.get(startActionId)?.row.latestTurn;
|
||
for (const [index, [receivedActionId, answer]] of answers.entries()) {
|
||
latest = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: receivedActionId,
|
||
turnVersion: index,
|
||
answer,
|
||
});
|
||
assert.equal(latest.status, index < 4 ? "active" : "confirming");
|
||
}
|
||
|
||
const stored = value.cases.get(startActionId)?.row;
|
||
const preBirth = stored?.eventEvidence.find((item) => item.dateValue === "1999-12");
|
||
assert.equal(preBirth?.scoreable, false);
|
||
assert.equal(preBirth?.extractionStatus, "needs_clarification");
|
||
assert.equal(stored?.eventEvidence.length, 5);
|
||
assert.equal(latest?.evidenceRecap.length, 5);
|
||
assert.deepEqual(value.packetEvidenceCounts, [1, 2, 2, 3, 4]);
|
||
});
|
||
|
||
test("vague, future, and unmatched answers stay conversational and never score", async () => {
|
||
for (const [answer, domain] of [
|
||
["后来换了工作", undefined],
|
||
["2099年3月开始新工作", undefined],
|
||
["这些都不符合,我想从家庭变化说起", "family"],
|
||
] as const) {
|
||
const value = harness();
|
||
await start(value, null);
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer,
|
||
...(domain ? { domain } : {}),
|
||
});
|
||
assert.equal(value.counts().packetBuilds, 1);
|
||
assert.equal(turn.status, "active");
|
||
assert.ok(turn.narrative.length > 0);
|
||
assert.match(turn.narrative, new RegExp(answer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||
assert.doesNotMatch(turn.narrative, /好的,我们不沿用不符合你的方向|已保存这段描述|我已保存你的原话|这条更正已保存/);
|
||
assert.doesNotMatch(turn.narrative, /A[.、:]|B[.、:]|2006.?2011/);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.scoreable, false);
|
||
}
|
||
});
|
||
|
||
test("a free Agent reply clears the previous structured question and adds an open guide", async () => {
|
||
const value = harness({
|
||
readyAfterEvidenceCount: 99,
|
||
continueLatestEvent: true,
|
||
freeNarrativeFromGeneration: 2,
|
||
});
|
||
await start(value, null);
|
||
|
||
const first = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2020年4月进入研究院实习",
|
||
});
|
||
assert.ok(first.evidenceRequest);
|
||
|
||
const second = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||
turnVersion: 1, answer: "2020年10月主动辞职,之后准备考研",
|
||
});
|
||
|
||
assert.equal(second.evidenceRequest, null);
|
||
assert.match(second.narrative, /顺着这段经历继续说|自然讲下一件/);
|
||
assert.match(second.narrative, /这件事之后,紧接着发生了什么?/);
|
||
});
|
||
|
||
test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => {
|
||
const value = harness({
|
||
packetFailure: new Error("synthetic packet outage"),
|
||
packetFailureFromBuild: 1,
|
||
});
|
||
const initial = await start(value, null);
|
||
|
||
const turn = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "化学专业",
|
||
});
|
||
|
||
assert.notEqual(turn.narrative, initial.narrative);
|
||
assert.match(turn.narrative, /化学专业|这轮|内容已经保留/);
|
||
assert.match(turn.narrative, /按自己的节奏/);
|
||
assert.equal(value.cases.get(startActionId)?.row.validationReceipts.at(-1)?.fallbackUsed, true);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.rawText, "化学专业");
|
||
});
|
||
|
||
test("resume returns the latest owned turn on a stale new-device version without mutation or charge", async () => {
|
||
const value = harness();
|
||
const initial = await start(value, null);
|
||
const paused = await value.service.pause(userId, {
|
||
type: "pause", caseId: startActionId, actionId: pauseActionId, turnVersion: 0,
|
||
});
|
||
assert.equal(paused.status, "paused");
|
||
assert.equal(paused.narrative, initial.narrative);
|
||
const latest = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 1, answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||
});
|
||
const mutationsBeforeResume = [...value.mutations];
|
||
const resumed = await value.service.resume(userId, {
|
||
type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 0,
|
||
});
|
||
assert.deepEqual(resumed, latest);
|
||
assert.deepEqual(value.mutations, mutationsBeforeResume);
|
||
assert.equal(value.counts().reserveCount, 1);
|
||
await assert.rejects(value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: laterActionId,
|
||
turnVersion: 0, answer: "2021年7月毕业",
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "stale_turn");
|
||
});
|
||
|
||
test("a lost-response retry replays the saved answer without rescoring or regenerating", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
const command = {
|
||
type: "answer" as const,
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||
};
|
||
const first = await value.service.answer(userId, command);
|
||
const before = [...value.events];
|
||
const replayed = await value.service.answer(userId, command);
|
||
assert.deepEqual(replayed, first);
|
||
assert.equal(value.counts().packetBuilds, 1);
|
||
assert.deepEqual(value.events, before);
|
||
});
|
||
|
||
test("regenerate rewrites only the current narrative and preserves evidence, scoring, candidate, and billing", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99, varyNarrative: true });
|
||
await start(value, null);
|
||
const answered = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2012年12月正式退学,2013年1月彻底离开学校",
|
||
});
|
||
const storedBefore = value.cases.get(startActionId)?.row;
|
||
assert.ok(storedBefore);
|
||
const evidenceBefore = structuredClone(storedBefore.eventEvidence);
|
||
const candidateBefore = structuredClone(storedBefore.privateCandidate);
|
||
const countsBefore = value.counts();
|
||
const scoreableCountBefore = value.packetEvidenceCounts.at(-1);
|
||
const scoreableIdsBefore = value.packetEvidenceIds.at(-1);
|
||
|
||
const regenerated = await value.service.regenerate(userId, {
|
||
type: "regenerate",
|
||
caseId: startActionId,
|
||
actionId: laterActionId,
|
||
turnVersion: answered.turnVersion,
|
||
});
|
||
|
||
const storedAfter = value.cases.get(startActionId)?.row;
|
||
assert.ok(storedAfter);
|
||
assert.equal(regenerated.turnVersion, answered.turnVersion + 1);
|
||
assert.notEqual(regenerated.narrative, answered.narrative);
|
||
assert.deepEqual(storedAfter.eventEvidence, evidenceBefore);
|
||
assert.deepEqual(storedAfter.privateCandidate, candidateBefore);
|
||
assert.equal(value.counts().reserveCount, countsBefore.reserveCount);
|
||
assert.equal(value.packetEvidenceCounts.at(-1), scoreableCountBefore);
|
||
assert.deepEqual(value.packetEvidenceIds.at(-1), scoreableIdsBefore);
|
||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2);
|
||
assert.match(value.narrativePrompts.at(-1) ?? "", /2012年12月正式退学/);
|
||
});
|
||
|
||
test("a failed regenerate saves a fallback turn without changing evidence, candidate, or billing", async () => {
|
||
const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 2 });
|
||
await start(value, null);
|
||
const answered = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2012年12月正式退学,2013年1月彻底离开学校",
|
||
});
|
||
const storedBefore = value.cases.get(startActionId)?.row;
|
||
assert.ok(storedBefore);
|
||
const evidenceBefore = structuredClone(storedBefore.eventEvidence);
|
||
const candidateBefore = structuredClone(storedBefore.privateCandidate);
|
||
const countsBefore = value.counts();
|
||
const saveTurnsBefore = value.mutations.filter((mutation) => mutation === "saveTurn").length;
|
||
|
||
const regenerated = await value.service.regenerate(userId, {
|
||
type: "regenerate",
|
||
caseId: startActionId,
|
||
actionId: laterActionId,
|
||
turnVersion: answered.turnVersion,
|
||
});
|
||
|
||
const storedAfter = value.cases.get(startActionId)?.row;
|
||
assert.equal(regenerated.turnVersion, answered.turnVersion + 1);
|
||
assert.match(regenerated.narrative, /这件事之后.*发生了什么/);
|
||
assert.deepEqual(storedAfter?.eventEvidence, evidenceBefore);
|
||
assert.deepEqual(storedAfter?.privateCandidate, candidateBefore);
|
||
assert.equal(value.counts().reserveCount, countsBefore.reserveCount);
|
||
assert.equal(value.counts().releaseCount, countsBefore.releaseCount);
|
||
assert.equal(value.counts().packetBuilds, countsBefore.packetBuilds + 1);
|
||
assert.equal(
|
||
value.mutations.filter((mutation) => mutation === "saveTurn").length,
|
||
saveTurnsBefore + 1,
|
||
);
|
||
});
|
||
|
||
test("overlapping identical answers converge on the first receipt despite different derived narratives", async () => {
|
||
const value = harness({ varyNarrative: true });
|
||
await start(value, null);
|
||
const command = {
|
||
type: "answer" as const,
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||
};
|
||
|
||
const [first, second] = await Promise.all([
|
||
value.service.answer(userId, command),
|
||
value.service.answer(userId, command),
|
||
]);
|
||
|
||
assert.deepEqual(second, first);
|
||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2);
|
||
assert.equal(value.events.filter((event) => event === "narrative").length, 2);
|
||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2);
|
||
});
|
||
|
||
test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => {
|
||
const scenarios = [
|
||
{
|
||
name: "answer",
|
||
async perform(value: ReturnType<typeof harness>) {
|
||
const command = {
|
||
type: "answer" as const,
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚",
|
||
};
|
||
return { command, first: await value.service.answer(userId, command) };
|
||
},
|
||
},
|
||
{
|
||
name: "pause",
|
||
async perform(value: ReturnType<typeof harness>) {
|
||
const command = {
|
||
type: "pause" as const,
|
||
caseId: startActionId,
|
||
actionId: pauseActionId,
|
||
turnVersion: 0,
|
||
};
|
||
return { command, first: await value.service.pause(userId, command) };
|
||
},
|
||
},
|
||
{
|
||
name: "abandon",
|
||
async perform(value: ReturnType<typeof harness>) {
|
||
const command = {
|
||
type: "abandon" as const,
|
||
caseId: startActionId,
|
||
actionId: laterActionId,
|
||
turnVersion: 0,
|
||
};
|
||
return { command, first: await value.service.abandon(userId, command) };
|
||
},
|
||
},
|
||
{
|
||
name: "confirm",
|
||
async perform(value: ReturnType<typeof harness>) {
|
||
const ready = await value.service.answer(userId, {
|
||
type: "answer",
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚",
|
||
});
|
||
const command = {
|
||
type: "confirm" as const,
|
||
caseId: startActionId,
|
||
actionId: confirmActionId,
|
||
turnVersion: ready.turnVersion,
|
||
time: "05:18",
|
||
};
|
||
return { command, first: await value.service.confirm(userId, command) };
|
||
},
|
||
},
|
||
] as const;
|
||
|
||
for (const scenario of scenarios) {
|
||
const value = harness();
|
||
await start(value, null);
|
||
const { command, first } = await scenario.perform(value);
|
||
value.forceLaterVersion(startActionId, first.turnVersion + 2);
|
||
const mutationsBeforeReplay = [...value.mutations];
|
||
const eventsBeforeReplay = [...value.events];
|
||
|
||
const replayed = await value.service[scenario.name](userId, command as never);
|
||
|
||
assert.deepEqual(replayed, first, scenario.name);
|
||
assert.deepEqual(value.mutations, mutationsBeforeReplay, scenario.name);
|
||
assert.deepEqual(value.events, eventsBeforeReplay, scenario.name);
|
||
}
|
||
});
|
||
|
||
test("receipt-first delayed replay rejects the same action id with a different command payload", async () => {
|
||
const value = harness();
|
||
await start(value, null);
|
||
const command = {
|
||
type: "answer" as const,
|
||
caseId: startActionId,
|
||
actionId: answerActionId,
|
||
turnVersion: 0,
|
||
domain: "education" as const,
|
||
answer: "2021年7月毕业",
|
||
};
|
||
const first = await value.service.answer(userId, command);
|
||
value.forceLaterVersion(startActionId, first.turnVersion + 2);
|
||
|
||
await assert.rejects(value.service.answer(userId, {
|
||
...command,
|
||
domain: "career",
|
||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||
&& error.code === "action_conflict");
|
||
});
|
||
|
||
test("confirm delegates to the atomic store call, preserves the old baseline until then, and returns the saved question", async () => {
|
||
const value = harness();
|
||
await start(value, "请继续回答原来的事业问题");
|
||
const ready = await value.service.answer(userId, {
|
||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||
turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚",
|
||
});
|
||
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
|
||
const before = value.mutations.length;
|
||
const confirmed = await value.service.confirm(userId, {
|
||
type: "confirm", caseId: startActionId, actionId: confirmActionId,
|
||
turnVersion: ready.turnVersion, time: "05:18",
|
||
});
|
||
assert.deepEqual(value.mutations.slice(before), ["confirm"]);
|
||
assert.equal(confirmed.status, "completed");
|
||
assert.equal(confirmed.candidate.status, "confirmed");
|
||
assert.equal(confirmed.narrative, ready.narrative);
|
||
assert.equal(confirmed.pendingConsultationQuestion, "请继续回答原来的事业问题");
|
||
assert.deepEqual(confirmed.actions, ["continue_original_question"]);
|
||
});
|