fix(rectification): dedupe post-adopt verify and skip this probe only
Adopted reverse-verify repeated already-asked collect stems, treated 「这题跳过」 as stopping the case, and promised holdout/OOS checks that never ran. Collect spoken stems no longer prefix a year. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,9 +52,12 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
|
||||
const completeBilling = sourceBetween(rectificationRoute, "async complete(usage) {", "async release() {");
|
||||
const releaseBilling = sourceBetween(rectificationRoute, "async release() {", " };\n\n try {");
|
||||
|
||||
// 旧:enum 到 stop_and_review
|
||||
// 新:增加 skip_probe;非 message 仍不计费
|
||||
// 原因:决策 4,核对跳过走即时结构化动作
|
||||
assert.match(
|
||||
rectificationRoute,
|
||||
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/,
|
||||
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/,
|
||||
);
|
||||
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
|
||||
assert.match(reserveBilling, /if \(action !== "message"\) \{[\s\S]*return \{ success: true, status: 200 \};/);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { publicNextAction } from "../src/lib/rectification-agentic/core/rectific
|
||||
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
adoptDeliveryFacts,
|
||||
templatePostAdoptExplain,
|
||||
validateAdoptNarration,
|
||||
type AdoptDeliveryFacts,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
@@ -681,14 +682,17 @@ test("adoptDeliveryFacts names the representative, range, stop class, and post-a
|
||||
assert.ok(split);
|
||||
assert.match(split!.label, /分不开/);
|
||||
assert.ok((split!.count ?? 0) >= 1);
|
||||
assert.ok(facts.post_adopt_verification.some((item) => (
|
||||
item.kind === "holdout" && item.domain === "education" && item.year === 2016
|
||||
)));
|
||||
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "family"));
|
||||
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "finance"));
|
||||
assert.ok(facts.post_adopt_verification.some((item) => (
|
||||
item.kind === "oos" && (item.domain === "health" || item.domain === "health_pressure")
|
||||
)));
|
||||
// 旧:holdout/oos 出现在 post_adopt_verification
|
||||
// 新:仅出现采用后真会问的 reverse_verify 项,且与 buildMethodFollowupPlan({accepted:true}) 首题一致
|
||||
// 原因:决策 3 推翻原任务书 §4
|
||||
assert.equal(facts.post_adopt_verification.every((item) => item.kind === "reverse_verify"), true);
|
||||
const acceptedPlan = planFrom(dossier, { accepted: true });
|
||||
if (acceptedPlan.next_followup) {
|
||||
assert.equal(facts.post_adopt_verification[0]?.domain, acceptedPlan.next_followup.domain);
|
||||
} else {
|
||||
assert.deepEqual(facts.post_adopt_verification, []);
|
||||
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
|
||||
}
|
||||
});
|
||||
|
||||
test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => {
|
||||
|
||||
@@ -107,7 +107,10 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
|
||||
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
|
||||
assert.match(chat, /void send\("opening", ""\)/);
|
||||
assert.match(route, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
|
||||
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/);
|
||||
// 旧:enum 到 stop_and_review
|
||||
// 新:增加 skip_probe(核对卡「这题跳过」)
|
||||
// 原因:决策 4,跳过只关本题
|
||||
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
|
||||
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
|
||||
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/an
|
||||
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
SKIP_PROBE_ACTION,
|
||||
STOP_ACTION,
|
||||
composeChoiceNarration,
|
||||
isStructuredChoiceUserText,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
|
||||
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
@@ -500,7 +501,7 @@ function adoptionDossier() {
|
||||
}
|
||||
|
||||
function persistChoiceAccounting(
|
||||
dossier: ReturnType<typeof twoProbeDossier>,
|
||||
dossier: ReturnType<typeof dossierFixture>,
|
||||
extra: Parameters<typeof fakeAccounting>[0] = {},
|
||||
) {
|
||||
return choiceAccounting({
|
||||
@@ -884,7 +885,8 @@ test("answering the last discriminator persists a year-locked family collect foc
|
||||
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
|
||||
assert.equal(schema.choice, undefined);
|
||||
assert.equal(schema.collect, true);
|
||||
assert.match(schema.prompt ?? "", /2021/);
|
||||
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
|
||||
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
|
||||
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
assert.equal(applied.nextChoiceReady, false);
|
||||
@@ -1198,6 +1200,9 @@ test("the public agent route treats structured choice as a non-model command", (
|
||||
assert.doesNotMatch(block, /authorizeUsage/);
|
||||
assert.match(route, /"answer_choice"/);
|
||||
assert.match(route, /"stop_and_review"/);
|
||||
assert.match(route, /"skip_probe"/);
|
||||
assert.match(route, /SKIP_PROBE_ACTION/);
|
||||
assert.doesNotMatch(route, /action === "skip_probe"[\s\S]{0,80}paused/);
|
||||
assert.match(route, /export const maxDuration = 240/);
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /isPersistedFocusId\(focusId\)/);
|
||||
@@ -1358,3 +1363,156 @@ test("degraded spoken collect strips discriminator identity", () => {
|
||||
assert.doesNotMatch(spoken ?? "", /2016/);
|
||||
assert.equal(spoken, spokenFollowupForUser(spokenFollowup));
|
||||
});
|
||||
|
||||
function withAcceptedTime<T extends { case: { accepted_time: string | null } }>(dossier: T): T {
|
||||
return {
|
||||
...dossier,
|
||||
case: { ...dossier.case, accepted_time: "05:07" },
|
||||
};
|
||||
}
|
||||
|
||||
function nextFocusHandler() {
|
||||
return {
|
||||
set_agentic_rectification_conversation_focus: (_fn: string, args: Record<string, unknown>) => ({
|
||||
focus: {
|
||||
id: NEXT_FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-09-04T08:00:00.000Z",
|
||||
resolved_at: null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("post-adopt skip_probe keeps the case open and asks the next verify question", async () => {
|
||||
const accounting = persistChoiceAccounting(withAcceptedTime(twoProbeDossier()), nextFocusHandler());
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: SKIP_PROBE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: RELOCATION_2015_PROBE.id,
|
||||
optionId: "skip_probe",
|
||||
expectedRevision: twoProbeInference().revision,
|
||||
});
|
||||
assert.equal(applied.optionId, "skip_probe");
|
||||
assert.notEqual(applied.nextAction.session_outcome, "provisional_range_user_stopped");
|
||||
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
|
||||
assert.equal(applied.nextChoiceReady, true);
|
||||
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus);
|
||||
assert.equal(setFocus.args.p_intent, "reverse_verify");
|
||||
const schema = setFocus.args.p_expected_answer_schema as { semantic_key?: string; choice?: { prompt?: string } };
|
||||
assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key);
|
||||
assert.match(applied.narration, /2014/);
|
||||
assert.doesNotMatch(applied.narration, /2015 年 5 月/);
|
||||
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
||||
assert.equal(persist?.args.p_focus_status, "skipped");
|
||||
const inference = persist?.args.p_inference as { answer_class?: string; raw_answer?: string } | null;
|
||||
assert.equal(inference?.answer_class, "unsure");
|
||||
assert.equal(inference?.raw_answer, "skip_probe");
|
||||
});
|
||||
|
||||
function lastVerifyDossier() {
|
||||
const inference = buildInferenceState({
|
||||
range_start: "04:45",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 16 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 14 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2024, precision: "day" },
|
||||
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
|
||||
{ id: "e4", domain: "career", year: 2026, precision: "day" },
|
||||
{ id: "e5", domain: "relationship", year: 2024, precision: "day" },
|
||||
],
|
||||
probes: [EDUCATION_2014_PROBE],
|
||||
});
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: {
|
||||
inference_state: inference,
|
||||
discriminating_event_probes: [
|
||||
eventProbeFromConflict(EDUCATION_2014_PROBE),
|
||||
],
|
||||
},
|
||||
});
|
||||
return dossierFixture({
|
||||
evidenceCount: 5,
|
||||
evidence: fourEventRows(),
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: activeFocusFixture({
|
||||
intent: "reverse_verify",
|
||||
targetDomain: "education",
|
||||
targetKind: "education_style",
|
||||
expectedAnswerSchema: {
|
||||
choice: {
|
||||
prompt: "2014 年前后,有没有升学、转学或换学习环境?",
|
||||
option_a: "明确发生且时间吻合",
|
||||
option_b: "发生过但程度较弱",
|
||||
option_c: "明确没有发生",
|
||||
option_d: "这段记不清楚",
|
||||
options: STYLE_OPTIONS.map((option, index) => ({
|
||||
key: (["A", "B", "C", "D"] as const)[index]!,
|
||||
label: option.label,
|
||||
answer_class: option.answer_class,
|
||||
})),
|
||||
},
|
||||
probe_id: EDUCATION_2014_PROBE.id,
|
||||
semantic_key: EDUCATION_2014_PROBE.semantic_key,
|
||||
candidate_split_hash: EDUCATION_2014_PROBE.candidate_split_hash,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
turns: [{
|
||||
id: TURN_ID,
|
||||
role: "assistant",
|
||||
text: "2014 年前后,有没有升学、转学或换学习环境?",
|
||||
status: "completed",
|
||||
created_at: "2026-08-28T07:36:54.000Z",
|
||||
completed_at: "2026-08-28T07:37:34.000Z",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
test("skipping the last post-adopt verify question closes with start_consultation", async () => {
|
||||
const dossier = withAcceptedTime(lastVerifyDossier());
|
||||
const accounting = persistChoiceAccounting(dossier, nextFocusHandler());
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: SKIP_PROBE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: EDUCATION_2014_PROBE.id,
|
||||
optionId: "skip_probe",
|
||||
expectedRevision: (
|
||||
dossier.latest_result as {
|
||||
decision_receipt: { inference_state: { revision: number } };
|
||||
}
|
||||
).decision_receipt.inference_state.revision,
|
||||
});
|
||||
assert.equal(applied.nextUserAction.id, "start_consultation");
|
||||
assert.equal(applied.narration, RECTIFICATION_USER_COPY.postAdoptVerifyDone);
|
||||
assert.equal(applied.nextChoiceReady, false);
|
||||
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -151,6 +151,30 @@ test("two scoreable events do not open OOS even when holdout prompts exist", ()
|
||||
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
|
||||
});
|
||||
|
||||
test("family collect spoken stem has no year prefix while probe_year stays dated", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: TWO_SCOREABLE,
|
||||
evidenceCollectionProbes: [{
|
||||
year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
domain: "family",
|
||||
event_family: "家人结婚、添丁或住院",
|
||||
source: "age_band",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: false,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
||||
role: "collect",
|
||||
phase: "evidence_collection",
|
||||
information_gain: 0,
|
||||
semantic_key: "family.2021",
|
||||
}],
|
||||
});
|
||||
assert.equal(plan.next_followup?.domain, "family");
|
||||
assert.equal(plan.next_followup?.probe_year, 2021);
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
|
||||
});
|
||||
|
||||
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
|
||||
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
|
||||
const plan = collectPlan(FOUR_SCOREABLE, {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
rectificationFollowupCatalog,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
@@ -484,7 +484,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
|
||||
assert.equal(plan.next_followup?.domain, "family");
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
assert.equal(plan.next_followup?.probe_year, 2021);
|
||||
assert.match(plan.next_followup?.year_label ?? "", /2021/);
|
||||
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
|
||||
// 原因:决策 7;probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
|
||||
assert.equal(plan.next_followup?.year_label, undefined);
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
|
||||
assert.equal(plan.dropped_probes.some((item) => (
|
||||
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
|
||||
)), true);
|
||||
|
||||
@@ -239,6 +239,7 @@ const ADVANCING_RECTIFICATION_ACTIONS = [
|
||||
"message",
|
||||
"answer_choice",
|
||||
"stop_and_review",
|
||||
"skip_probe",
|
||||
] as const;
|
||||
|
||||
function routeExitContract(route: string, turnExit: string) {
|
||||
@@ -284,7 +285,10 @@ test("nonterminal invariant 1: every advancing action exits through an awaited c
|
||||
failures.push("schema actions and execution declarations differ");
|
||||
}
|
||||
for (const action of ADVANCING_RECTIFICATION_ACTIONS) {
|
||||
const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review";
|
||||
const immediate = action === "message"
|
||||
|| action === "answer_choice"
|
||||
|| action === "stop_and_review"
|
||||
|| action === "skip_probe";
|
||||
const streamed = action === "opening" || action === "message";
|
||||
if (immediate && !contract.immediateAwaited) {
|
||||
failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseWindowScan,
|
||||
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
|
||||
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
@@ -2266,9 +2267,12 @@ test("family collect attaches the collection-probe year instead of a yearless D2
|
||||
assert.equal(plan.next_followup?.domain, "family");
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
assert.equal(plan.next_followup?.probe_year, 2021);
|
||||
assert.match(plan.next_followup?.year_label ?? "", /2021/);
|
||||
// 旧:year_label / spoken 带「2021 年前后,」前缀
|
||||
// 新:probe_year 仍是 2021,口语等于 USER_COLLECT_QUESTION.family,不填 year_label
|
||||
// 原因:决策 7,年份只用于计分与去重,不进采集题干
|
||||
assert.equal(plan.next_followup?.year_label, undefined);
|
||||
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
|
||||
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里/);
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
|
||||
});
|
||||
|
||||
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
templatePostAdoptExplain,
|
||||
validateAdoptNarration,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
import {
|
||||
buildMethodFollowupPlan,
|
||||
buildNextUserAction,
|
||||
spokenFollowupForUser,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationQuestionGapState,
|
||||
rectificationQuestionRetryActive,
|
||||
type RectificationQuestionGapInput,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const STYLE_OPTIONS = [
|
||||
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
|
||||
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
|
||||
{ label: "明确没有发生", answer_class: "no" as const },
|
||||
{ label: "这段记不清楚", answer_class: "unsure" as const },
|
||||
];
|
||||
|
||||
function verifyProbe(input: {
|
||||
domain: "finance" | "relocation";
|
||||
year: number;
|
||||
semantic: string;
|
||||
split: string;
|
||||
}) {
|
||||
return {
|
||||
year: input.year,
|
||||
year_label: `${input.year} 年前后`,
|
||||
domain: input.domain,
|
||||
event_family: input.domain === "finance"
|
||||
? "收入明显变化、大笔支出或欠债"
|
||||
: "搬家或长期住到外地",
|
||||
source: "dasha_activation" as const,
|
||||
tracks: ["vimshottari", "narayana"] as const,
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false as const,
|
||||
user_meaning: `${input.year} 年前后是否有相关经历。`,
|
||||
role: "distinguish" as const,
|
||||
style_options: STYLE_OPTIONS,
|
||||
semantic_key: input.semantic,
|
||||
candidate_split_hash: input.split,
|
||||
};
|
||||
}
|
||||
|
||||
const FINANCE_2013 = verifyProbe({
|
||||
domain: "finance",
|
||||
year: 2013,
|
||||
semantic: "finance.2013.verify",
|
||||
split: "finance-2013-split",
|
||||
});
|
||||
const RELOC_2017 = verifyProbe({
|
||||
domain: "relocation",
|
||||
year: 2017,
|
||||
semantic: "relocation.2017.verify",
|
||||
split: "reloc-2017-split",
|
||||
});
|
||||
|
||||
const EDUCATION_EVIDENCE = [{
|
||||
status: "confirmed" as const,
|
||||
domain: "education",
|
||||
datePrecision: "year" as const,
|
||||
occurredFrom: "2008-01-01",
|
||||
occurredTo: null,
|
||||
}];
|
||||
|
||||
const gapBase: RectificationQuestionGapInput = {
|
||||
liveQuestionVisible: false,
|
||||
questionMissing: true,
|
||||
questionLoadFailed: false,
|
||||
busy: false,
|
||||
readonly: false,
|
||||
regenerating: false,
|
||||
snapshotLoaded: true,
|
||||
resumableCase: true,
|
||||
retryAttempts: 0,
|
||||
};
|
||||
|
||||
test("post-adopt reverse-verify skips a probe already present in askedKeys", () => {
|
||||
const remaining = buildMethodFollowupPlan({
|
||||
evidence: EDUCATION_EVIDENCE,
|
||||
accepted: true,
|
||||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||||
askedProbeKeys: [FINANCE_2013.semantic_key],
|
||||
});
|
||||
assert.equal(remaining.next_followup?.domain, "relocation");
|
||||
assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key);
|
||||
|
||||
const none = buildMethodFollowupPlan({
|
||||
evidence: EDUCATION_EVIDENCE,
|
||||
accepted: true,
|
||||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||||
askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key],
|
||||
});
|
||||
assert.equal(none.next_followup, null);
|
||||
const action = buildNextUserAction({
|
||||
scorableCount: 1,
|
||||
evidenceCount: 1,
|
||||
hasLatestResult: true,
|
||||
selectionAllowed: true,
|
||||
sessionOutcome: "adopt_representative",
|
||||
nextFollowup: none.next_followup,
|
||||
workingTime: "05:07",
|
||||
accepted: true,
|
||||
});
|
||||
assert.equal(action.id, "start_consultation");
|
||||
});
|
||||
|
||||
test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: EDUCATION_EVIDENCE,
|
||||
accepted: true,
|
||||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||||
askedProbeKeys: [FINANCE_2013.candidate_split_hash],
|
||||
});
|
||||
assert.equal(plan.next_followup?.domain, "relocation");
|
||||
assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key);
|
||||
});
|
||||
|
||||
test("empty reverse-verify list forbids a 会拿…核对 promise", () => {
|
||||
const facts = {
|
||||
precision_stage: "ready_to_adopt" as const,
|
||||
already_accepted: false,
|
||||
credible_range: ["05:00", "05:06"] as const,
|
||||
representative_minute: "05:00",
|
||||
runner_up_minute: "05:06",
|
||||
opening_window: ["04:45", "05:15"] as const,
|
||||
active_candidates: [],
|
||||
answered_rounds: 5,
|
||||
stop_facts: [],
|
||||
post_adopt_verification: [],
|
||||
minutes: ["05:00", "05:06"],
|
||||
years: [],
|
||||
};
|
||||
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
|
||||
const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts);
|
||||
assert.equal(denied.ok, false);
|
||||
if (!denied.ok) assert.equal(denied.reason, "promise");
|
||||
});
|
||||
|
||||
test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => {
|
||||
assert.equal(rectificationQuestionGapState({
|
||||
...gapBase,
|
||||
nextUserActionId: "start_consultation",
|
||||
}), "verified_idle");
|
||||
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
|
||||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||||
assert.equal(rectificationQuestionGapState({
|
||||
...gapBase,
|
||||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
}), "unavailable");
|
||||
});
|
||||
|
||||
test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => {
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(chat, /questionGap === "verified_idle"/);
|
||||
assert.match(chat, /RECTIFICATION_USER_COPY\.postAdoptVerifyDone/);
|
||||
assert.match(chat, /skip_this_probe/);
|
||||
assert.match(chat, /SKIP_PROBE_ACTION/);
|
||||
assert.doesNotMatch(chat, /rectification-consult-handoff/);
|
||||
const idle = chat.slice(chat.indexOf('questionGap === "verified_idle"'));
|
||||
assert.doesNotMatch(idle.slice(0, 400), /rectification-question-gap__copy/);
|
||||
assert.doesNotMatch(idle.slice(0, 400), /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /已采用 \{savedTime\}/);
|
||||
assert.match(chat, /之后新建对话即按此时间排盘/);
|
||||
});
|
||||
|
||||
test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe_year stays dated", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null },
|
||||
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null },
|
||||
],
|
||||
evidenceCollectionProbes: [{
|
||||
year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
domain: "family",
|
||||
event_family: "家人结婚、添丁或住院",
|
||||
source: "age_band",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: false,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
||||
role: "collect",
|
||||
phase: "evidence_collection",
|
||||
information_gain: 0,
|
||||
semantic_key: "family.2021",
|
||||
}],
|
||||
});
|
||||
assert.equal(plan.next_followup?.domain, "family");
|
||||
assert.equal(plan.next_followup?.probe_year, 2021);
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
const spoken = spokenFollowupForUser(plan.next_followup);
|
||||
assert.equal(spoken, USER_COLLECT_QUESTION.family);
|
||||
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
|
||||
});
|
||||
@@ -505,10 +505,13 @@ test("spoken collect followup persists a collect focus without a choice card", a
|
||||
assert.equal(result.focus?.intent, "collect_method_evidence");
|
||||
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
|
||||
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
|
||||
assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/);
|
||||
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family
|
||||
// 原因:决策 7,采集题不拼年份前缀;fixture 仍带 probe_year 供计分
|
||||
assert.equal(result.focus?.expectedAnswerSchema.prompt, USER_COLLECT_QUESTION.family);
|
||||
const openCollect = openQuestionFromPersistedFocus(result);
|
||||
assert.equal(openCollect?.kind, "collect_spoken");
|
||||
assert.match(openCollect?.prompt ?? "", /2021/);
|
||||
assert.equal(openCollect?.prompt, USER_COLLECT_QUESTION.family);
|
||||
assert.doesNotMatch(openCollect?.prompt ?? "", /2021/);
|
||||
assert.notEqual(openCollect?.unrenderable, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ test("the question gap is a live row with retries, then a reload; it never tells
|
||||
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
|
||||
assert.match(chat, /questionGap === "preparing" && \(/);
|
||||
assert.match(chat, /questionGap === "unavailable" && \(/);
|
||||
assert.match(chat, /questionGap === "verified_idle" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
|
||||
|
||||
@@ -56,6 +56,7 @@ test("question gap: preparing while retries remain, then unavailable with a relo
|
||||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("idle"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
|
||||
});
|
||||
|
||||
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
|
||||
|
||||
Reference in New Issue
Block a user