feat: make birth-time rectification conversational
This commit is contained in:
@@ -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:16–05: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);
|
||||
});
|
||||
Reference in New Issue
Block a user