feat: make birth-time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-23 13:12:09 +08:00
parent 200c39d22b
commit f9010c8797
34 changed files with 1720 additions and 177 deletions
@@ -20,7 +20,7 @@ test("journey engine serializes only stored event-scoring inputs", () => {
lon: 121.4737,
tz: 8,
events: [
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month" },
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
],
@@ -34,7 +34,7 @@ test("journey engine serializes only stored event-scoring inputs", () => {
lon: 121.4737,
tz: 8,
events: [
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month" },
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
],
@@ -180,13 +180,14 @@ test("rectify-first suggestions hand the source question to a dedicated rectific
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
});
test("completed handoffs automatically return and continue the source question", () => {
test("completed handoffs return only after the user clicks and target the source session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /turn\.status !== "completed"/);
assert.match(source, /turn\.actions\.includes\("continue_original_question"\)/);
assert.match(source, /automaticRectificationContinuation\.current === continuationIdentity/);
assert.match(source, /continueRectificationQuestion\.current\(question\)/);
assert.doesNotMatch(source, /automaticRectificationContinuation/);
assert.match(source, /const returnSession = \(localHandoff/);
assert.match(source, /session\.sessionType === "consultation"/);
assert.match(source, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
assert.match(source, /sessionId: returnSession\.id/);
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
});
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAXIMUM_SCOREABLE_EVENTS,
rangeCompletionReason,
} from "../src/lib/conversational-rectification/convergence.ts";
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
const pendingPacket = {
candidate: { status: "pending_validation" },
suggestedDomains: [
{ domain: "relationship", layer: "D9", reason: "relationship evidence" },
{ domain: "finance", layer: "D11", reason: "finance evidence" },
],
} as RectificationTechnicalPacket;
test("does not stop at the evidence count limit while discriminating domains remain unanswered", () => {
assert.equal(rangeCompletionReason({
packet: pendingPacket,
scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS,
plateauCount: 1,
unansweredSuggestedDomainCount: 2,
}), null);
});
test("stops at the evidence count limit after the suggested domains are covered", () => {
assert.equal(rangeCompletionReason({
packet: pendingPacket,
scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS,
plateauCount: 1,
unansweredSuggestedDomainCount: 0,
}), "evidence_limit");
});
@@ -24,6 +24,21 @@ test("preserves raw text and splits two clear facts sharing an explicit month",
assert.equal(new Set(evidence.map((item) => item.id)).size, 2);
});
test("merges same-date same-domain clauses into one scoreable life event", () => {
const rawText = "2017年7月入职第一家公司,并从事数据分析工作";
const evidence = extractLifeEventEvidence({
rawText,
sourceTurnId,
asOfDate: "2026-07-20",
});
assert.equal(evidence.length, 1);
assert.equal(evidence[0]?.domain, "career");
assert.equal(evidence[0]?.dateValue, "2017-07");
assert.equal(evidence[0]?.eventSummary, "入职第一家公司;从事数据分析工作");
assert.equal(evidence[0]?.scoreable, true);
});
test("removes the date-picker transport labels from the visible event summary", () => {
const rawText = "发生时间:2016 年 6 月\n事件详情:大学毕业";
const [evidence] = extractLifeEventEvidence({
@@ -121,6 +136,21 @@ test("classifies dated income and asset changes as finance evidence", () => {
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
});
for (const rawText of [
"2020年8月开始承担管理职责",
"2020年8月职位发生明显变化",
"2020年8月开始任职部门负责人",
]) {
test(`classifies dated role and management changes as career evidence: ${rawText}`, () => {
const [evidence] = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" });
assert.equal(evidence?.domain, "career");
assert.equal(evidence?.dateValue, "2020-08");
assert.equal(evidence?.scoreable, true);
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
});
}
test("keeps a bare year as non-scoreable clarification instead of an event summary", () => {
const rawText = "2021年";
const [evidence] = extractLifeEventEvidence({
@@ -119,6 +119,201 @@ test("validates a rich first-turn narrative against the technical packet", async
assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/);
});
test("repairs missing safety wording without replacing the model-authored narrative", async () => {
const packet = syntheticTechnicalPacket();
const output = {
...richOutput(),
narrative: "我们先从你印象最深的一次关系变化聊起,好吗?",
evidenceRequest: {
domains: ["relationship" as const],
datePrecision: "month_preferred" as const,
prompt: "那段关系大约是什么时候发生的?",
},
};
const result = await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([output]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /印象最深的一次关系变化/);
assert.match(result.narrative, /05:1605:24/);
assert.match(result.narrative, /不能直接当作已经确认/);
assert.match(result.narrative, /关系变化[\s\S]*什么时候发生/);
assert.doesNotMatch(result.narrative, /请只提供已经发生/);
assert.match(result.output.evidenceRequest?.prompt ?? "", /关系大约是什么时候/);
assert.doesNotMatch(result.output.evidenceRequest?.prompt ?? "", /请以已经发生的真实事件为准/);
});
test("repairs a natural intermediate follow-up prompt without discarding the specific reply", async () => {
const packet = syntheticTechnicalPacket();
const output = {
...richOutput(),
narrative: "你把毕业和读研的衔接说清楚了,这两段会分别记录。下一步想确认毕业后的第一份工作是什么时候开始的?",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "第一份工作是什么时候开始的?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet,
generator: generator([output]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /毕业和读研的衔接/);
assert.doesNotMatch(result.narrative, /当前累计/);
assert.equal(result.output.evidenceRequest?.prompt, "第一份工作是什么时候开始的?");
});
test("makes an internal evidence prompt visible when the acknowledgement contains no question", async () => {
const output = {
...richOutput(),
narrative: "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "毕业后的第一份工作是什么时候开始的?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /连续教育转折/);
assert.match(result.narrative, /第一份工作是什么时候开始的/);
assert.doesNotMatch(result.narrative, /请只提供已经发生/);
});
test("adds the concrete question when the acknowledgement only says whether more detail is needed", async () => {
const output = {
...richOutput(),
narrative: "这次入职会作为一条事业事件记录,但还需要知道后续是否发生过离职、转岗或升职。",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "这份工作后来第一次发生明确变化是在什么时候?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /还需要知道后续是否发生过/);
assert.match(result.narrative, /第一次发生明确变化是在什么时候/);
});
test("passes the bounded expert workflow to the skill-guided narrator", async () => {
const prompts: string[] = [];
const packet = {
...syntheticTechnicalPacket(),
expertWorkflow: {
boundary: "not_auto_rectified" as const,
candidateWindows: [{
startTime: "05:16",
endTime: "05:24",
status: "pending_validation" as const,
}],
techniqueAuditTable: [{
technique: "KP cusp / sub-lord",
status: "blocked" as const,
evidence: [],
boundary: "当前评分合同没有可审计结果。",
}],
confirmationAllowed: false,
hardBlockers: ["minute_holdout_not_ready"],
gates: {},
},
};
await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([richOutput()], prompts),
});
assert.match(prompts[0] ?? "", /expertWorkflow/);
assert.match(prompts[0] ?? "", /KP cusp \/ sub-lord/);
assert.match(prompts[0] ?? "", /blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed/);
});
test("passes the user's latest concrete event to an intermediate skill-guided reply", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestEvidence: [{
dateLabel: "2023-09",
summary: "离开家乡去上海开始第一份长期工作",
domain: "career",
}],
},
generator: generator([richOutput()], prompts),
});
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
assert.match(prompts[0] ?? "", /acknowledgeLatestEvidenceSpecificallyBeforeAsking/);
assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/);
});
test("passes the active event ledger and unresolved facts to the intermediate agent", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestUserText: "23年关系结束后发生过一次交通事故",
latestEvidence: [{
dateLabel: "2023",
summary: "关系结束后发生过一次交通事故",
domain: "health_pressure",
}],
eventLedger: [{
id: "relationship-ending",
rawText: "2024年8月8日一段重要关系结束",
dateLabel: "2024-08-08",
summary: "一段重要关系结束",
domain: "relationship",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
unresolvedEvidence: [{
id: "accident-year-conflict",
rawText: "23年关系结束后发生过一次交通事故",
summary: "关系结束后发生过一次交通事故",
domain: "health_pressure",
dateLabel: "2023",
}],
},
generator: generator([richOutput()], prompts),
});
const prompt = prompts[0] ?? "";
assert.match(prompt, /23年关系结束后发生过一次交通事故/);
assert.match(prompt, /2024-08-08/);
assert.match(prompt, /一段重要关系结束/);
assert.match(prompt, /finishCurrentEventBeforeSwitchingDomains/);
assert.match(prompt, /resolveDateContradictionsBeforeScoring/);
assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/);
});
test("rejects invented representative times, layers, and references", () => {
const packet = syntheticTechnicalPacket();
const invalid = {
@@ -244,6 +439,24 @@ test("accepts a request for one real past event's year and month without propose
);
});
test("accepts a natural reply that mentions several known dates before asking a non-year alternative", () => {
const output = richOutput();
const legitimate = {
...output,
narrative: "你在2014年开始读研,并在2017年正常毕业,这条教育线已经完整。毕业后的第一份工作是直接入职,还是先休息了一段时间?",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "毕业后的第一份工作是什么时候开始的?",
},
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(
validateNarrativeAgainstPacket(legitimate, syntheticTechnicalPacket(), "intermediate"),
{ valid: true, issues: [] },
);
});
test("rejects ungrounded layers and references nested in a domain reason", () => {
const output = richOutput();
const firstReason = output.domainReasons[0];
@@ -122,6 +122,29 @@ test("a resumed legacy turn replaces repeated technical prose with actionable gu
assert.doesNotMatch(markup, /D1 保持稳定/);
});
test("an incomplete concrete event gets a targeted clarification instead of the generic question", () => {
const incompleteTurn = {
...turn,
status: "active",
candidate: { ...turn.candidate, status: "pending_validation" },
evidenceRecap: [{
...turn.evidenceRecap[0]!,
summary: "离开家去北京开始工作",
dateLabel: "日期待补充",
domain: "relocation",
}],
actions: ["answer", "pause", "abandon"],
} satisfies ConversationalRectificationTurn;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller({ turn: incompleteTurn }) },
));
assert.match(markup, /你提到“离开家去北京开始工作”/);
assert.match(markup, /大致是什么年月/);
assert.doesNotMatch(markup, /接下来请说一件/);
});
test("an uninitialized surface shows progress without a second start card", () => {
const emptyController = controller({
turn: null,
@@ -773,6 +796,26 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
"correction cancellation",
);
const mistakenAnswer = "2020年9月离职写错了";
await cdp.evaluate(`(() => {
const textarea = document.getElementById('conversational-rectification-answer');
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(textarea, ${JSON.stringify(mistakenAnswer)});
textarea.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('[aria-label="发送"]').click();
})()`);
await waitFor(
() => cdp?.evaluate<boolean>(`document.querySelector('[aria-label="撤回发送,本次不计入校正"]') !== null`) ?? Promise.resolve(false),
"rectification undo window",
);
await cdp.evaluate("document.querySelector('[aria-label=\"撤回发送,本次不计入校正\"]').click()");
await waitFor(
() => cdp?.evaluate<boolean>(`document.getElementById('conversational-rectification-answer').value === ${JSON.stringify(mistakenAnswer)}`) ?? Promise.resolve(false),
"mistaken answer restored to draft",
);
await new Promise((resolve) => setTimeout(resolve, 2_700));
assert.equal(await cdp.evaluate<boolean>("globalThis.__rectificationHarness.events.some((event) => event.endsWith(':answer'))"), false);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
@@ -114,6 +114,27 @@ test("controller admits only one in-flight mutation and clears text only after s
assert.deepEqual(pendingChanges, [true, false]);
});
test("controller retains alternating user and Agent messages after each answer", async () => {
const initial = activeTurn();
const controller = createConversationalRectificationController({
initialTurn: initial,
createActionId: idFactory(),
send: async () => ({
...activeTurn(3),
narrative: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。",
}),
});
controller.setDraft("2021 年 7 月开始第一份工作");
await controller.answer("career");
assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [
{ role: "assistant", text: initial.narrative },
{ role: "user", text: "2021 年 7 月开始第一份工作" },
{ role: "assistant", text: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。" },
]);
});
test("controller preserves the exact draft and stable action id across failures", async () => {
const commands: ConversationalRectificationCommand[] = [];
const controller = createConversationalRectificationController({
@@ -393,6 +414,11 @@ test("a case switch detaches the old mutation so the new case can mutate indepen
controller.synchronizeInitialTurn(caseBTurn);
assert.deepEqual(controller.getSnapshot(), {
turn: caseBTurn,
messages: [{
role: "assistant",
text: caseBTurn.narrative,
renderKey: "assistant-1",
}],
draft: "",
selectedDomain: null,
correctionTarget: null,
@@ -437,6 +463,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
controller.synchronizeInitialTurn(null);
assert.deepEqual(controller.getSnapshot(), {
turn: null,
messages: [],
draft: "",
selectedDomain: null,
correctionTarget: null,
@@ -448,6 +475,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
await rejected;
assert.deepEqual(controller.getSnapshot(), {
turn: null,
messages: [],
draft: "",
selectedDomain: null,
correctionTarget: null,
@@ -492,7 +492,9 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004",
turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大",
});
assert.match(turn.narrative, /还缺少.*明确时间/);
assert.match(turn.narrative, /工作压力很大/);
assert.match(turn.narrative, /什么年月/);
assert.doesNotMatch(turn.narrative, /请提供.*真实事件/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
const futureEvidenceAction = "00000000-0000-4000-8000-000000009050";
@@ -99,6 +99,7 @@ function validGenerator(
events: string[],
varyNarrative = false,
invalidNarrativeFromGeneration?: number,
prompts: string[] = [],
) {
let generation = 0;
return {
@@ -106,12 +107,17 @@ function validGenerator(
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";
conversationContext?: {
latestEvidence?: Array<{ dateLabel: string; summary: string; domain: string }>;
eventLedger?: Array<{ domain: string; active: boolean }>;
};
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
candidate: ReturnType<typeof packet>["candidate"] & {
rangeStart: string;
@@ -120,10 +126,18 @@ function validGenerator(
};
};
const value = request.packet;
const domains = value.suggestedDomains.slice(0, 1).map((item) => item.domain);
const answeredDomains = new Set(request.conversationContext?.eventLedger
?.filter((item) => item.active)
.map((item) => item.domain) ?? []);
const nextSuggested = value.suggestedDomains.find((item) => !answeredDomains.has(item.domain))
?? value.suggestedDomains[0];
const domains = nextSuggested ? [nextSuggested.domain] : [];
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
const latest = request.conversationContext?.latestEvidence?.at(-1);
const narrative = [
`当前仍在核对 ${value.candidate.rangeStart}${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
request.phase === "intermediate" && latest
? `记下了:${latest.dateLabel} · ${latest.summary}`
: `当前仍在核对 ${value.candidate.rangeStart}${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
request.phase === "final" ? "当前证据已形成候选总结。" : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
].join("");
@@ -161,6 +175,7 @@ function harness(options: {
readonly invalidNarrativeFromGeneration?: number;
} = {}) {
const events: string[] = [];
const narrativePrompts: string[] = [];
const mutations: string[] = [];
const cases = new Map<string, MutableCase>();
const receipts = new Map<string, {
@@ -388,12 +403,14 @@ function harness(options: {
events,
options.varyNarrative,
options.invalidNarrativeFromGeneration,
narrativePrompts,
),
asOfDate: () => "2026-07-21",
};
return {
events,
narrativePrompts,
mutations,
packetEvidenceCounts,
packetEvidenceIds,
@@ -817,6 +834,60 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
.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.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-03 · 我离开家去北京开始工作/);
});
test("the next evidence request moves past a domain the user already answered", 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.deepEqual(turn.evidenceRequest?.domains, ["career"]);
assert.match(turn.narrative, /事业/);
assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/);
});
test("a rejected professional narrative falls back safely while the first scoreable answer still narrows", async () => {
const value = harness({ invalidNarrativeFromGeneration: 2 });
await start(value, null);
@@ -859,21 +930,46 @@ test("one and two supported events save and narrate before the third accumulated
assert.equal(stored?.eventEvidence.length, index + 1);
assert.equal(turn.evidenceRecap.length, index + 1);
assert.equal(turn.status, index < 2 ? "active" : "confirming");
assert.match(turn.narrative, new RegExp(`当前累计 ${index + 1} 条可评分经历|候选范围已从|本轮已纳入 ${index + 1} 条可评分经历`));
assert.match(turn.narrative, /已记录/);
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("a non-confirmable conversational case completes with its saved range instead of asking forever", async () => {
test("intermediate narrative receives the complete active 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.match(prompt, /eventLedger/);
});
test("a non-confirmable conversational case asks each discriminating domain before completing its saved range", 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;
@@ -885,6 +981,10 @@ test("a non-confirmable conversational case completes with its saved range inste
turnVersion: index,
answer,
});
if (index === 2) {
assert.equal(latest.status, "active");
assert.deepEqual(latest.evidenceRequest?.domains, ["relationship"]);
}
}
assert.equal(latest?.status, "completed");
@@ -25,8 +25,10 @@ test("production narrator loads the Jyotish Skill without overriding packet trut
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
assert.match(source, /skills:\s*\[jyotishSkillPath\]/);
assert.match(source, /Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording/);
assert.match(source, /Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy/);
assert.match(source, /explain the supplied expert workflow/);
assert.match(source, /supplied packet facts as the exclusive source/);
assert.match(source, /blocked or not_evaluated technique must never be described as used/);
assert.match(source, /Never invent, recalculate, or confirm candidate data/);
});
@@ -655,6 +657,11 @@ test("production packet waits for three supported events and then scores the acc
assert.equal(scoreCalls.length, 1);
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
assert.deepEqual(
scoreCalls[0]?.map((event) => event.summary),
evidence.map((item) => item.eventSummary),
"event scoring must retain the concrete user-reported fact, not only domain and date",
);
assert.deepEqual(
differenceCalls.map((input) => input.events.map((event) => event.id)),
[evidence.slice(0, 1), evidence.slice(0, 2), evidence].map((items) => items.map((item) => item.id)),
@@ -342,7 +342,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
assert.deepEqual(calls.map(([name]) => name), [
"save_conversational_rectification_turn",
"pause_conversational_rectification_case",
"abandon_conversational_rectification_case",
"abandon_conversational_rectification_without_result",
"confirm_conversational_rectification_candidate",
"import_legacy_conversational_rectification_case",
]);
@@ -175,6 +175,66 @@ test("builds a deterministic private packet from server-computed engine receipts
}]);
});
test("projects the server technique receipt into a bounded expert workflow", () => {
const packet = buildRectificationTechnicalPacket({
scan,
candidateDifferences,
eventScore: {
...eventScore,
techniqueReceipt: {
calculationStatus: "evaluated",
usedDivisionalCharts: ["D9", "D10"],
usedArudha: ["UL", "A7", "A10"],
dashaTracks: ["vimshottari_md_ad_pd", "narayana_md_ad"],
missingLayers: ["shadbala_kala_dig_chesta_total"],
auxiliaryLayers: ["functional_benefic_malefic", "ashtakavarga"],
hardBlockers: ["minute_holdout_not_ready"],
confirmationAllowed: false,
decision: "continue_rectification",
gates: {
public_holdout_release: {
status: "blocked",
reason: "frozen_public_AA_minute_holdout_is_below_20_cases",
},
},
},
},
consultation: {
source: "server_consultation_workflow",
calculationVersion: "rectification-technical-v1",
availableLayers: ["D1", "D9", "D10"],
layerReferences: {
D1: ["consult-d1-ascendant"],
D9: ["consult-d9-candidate-difference"],
D10: ["consult-d10-candidate-difference"],
},
timeLinkedScanSamples: [
{ sampleIndex: 0, time: "05:10" },
{ sampleIndex: 1, time: "05:16" },
{ sampleIndex: 2, time: "05:17" },
{ sampleIndex: 3, time: "05:30" },
],
boundaryDistanceMinutes: 4,
futureWindows: [],
},
});
assert.equal(packet.expertWorkflow?.boundary, "not_auto_rectified");
assert.deepEqual(packet.expertWorkflow?.candidateWindows, [{
startTime: "05:16",
endTime: "05:24",
status: "pending_validation",
}]);
const audit = new Map(packet.expertWorkflow?.techniqueAuditTable.map((row) => [row.technique, row]));
assert.equal(audit.get("Vimshottari Dasha")?.status, "used");
assert.equal(audit.get("Narayana Dasha")?.status, "used");
assert.equal(audit.get("UL / A7 / A10")?.status, "used");
assert.equal(audit.get("Shadbala / Ashtakavarga")?.status, "partial");
assert.equal(audit.get("KP cusp / sub-lord")?.status, "blocked");
assert.equal(audit.get("Minute confirmation")?.status, "blocked");
assert.equal(packet.expertWorkflow?.confirmationAllowed, false);
});
test("chooses one strongest technical layer per domain from actual candidate switches", () => {
const differenceDrivenScan = {
...scan,
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
import { visibleRectificationNarrative } from "../src/lib/conversational-rectification/visible-narrative.ts";
function turn(overrides: Partial<ConversationalRectificationTurn> = {}): ConversationalRectificationTurn {
return {
caseId: "00000000-0000-4000-8000-000000000921",
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion: 1,
narrative: "internal narrative",
candidate: {
status: "pending_validation",
representativeTime: "05:20",
rangeStart: "04:50",
rangeEnd: "05:50",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: {
domains: ["relationship", "career"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
...overrides,
};
}
test("visible rectification copy preserves the Agent's concrete follow-up", () => {
const narrative = visibleRectificationNarrative(turn({
narrative: "你提到离开家去北京开始工作,这次迁居和工作变化很关键。它大致是什么年月?",
evidenceRecap: [{
id: "00000000-0000-4000-8000-000000000922",
summary: "离开家去北京开始工作",
dateLabel: "日期待补充",
domain: "relocation",
isCorrection: false,
}],
}));
assert.match(narrative, /你提到离开家去北京开始工作/);
assert.match(narrative, /大致是什么年月/);
assert.doesNotMatch(narrative, /接下来请说一件/);
});
test("visible rectification copy does not replace a tailored Agent answer with a template", () => {
const narrative = visibleRectificationNarrative(turn({
narrative: "结婚这件事我已经记下了。接下来想核对一次事业转折:你是哪一年、哪一月开始第一份长期工作的?",
evidenceRecap: [{
id: "00000000-0000-4000-8000-000000000923",
summary: "结婚",
dateLabel: "2020-05",
domain: "relationship",
isCorrection: false,
}],
}));
assert.match(narrative, /结婚这件事我已经记下了/);
assert.match(narrative, /事业转折/);
});
test("the rectification chat renders the controller's alternating message history", () => {
const source = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
assert.match(source, /controller\.messages/);
assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/);
const messageHistoryIndex = source.indexOf("controller.messages");
const progressDetailsIndex = source.indexOf("rectification-progress-details");
const evidenceRecapIndex = source.indexOf("turn.evidenceRecap.map");
assert.ok(messageHistoryIndex >= 0 && messageHistoryIndex < progressDetailsIndex);
assert.ok(evidenceRecapIndex > progressDetailsIndex);
});