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, withFirstDatedCollectInvite, } 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, /postAdoptVerifyDoneCopy/); 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/); // 原值: 状态条「已采用 {savedTime}」+「之后新建对话即按此时间排盘」 // 新值: verified_idle 走 postAdoptVerifyDoneCopy;无核对题用新句 // 原因: BUG-595 决策 3 assert.doesNotMatch(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); // 旧:口语等于 USER_COLLECT_QUESTION.family // 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」 // 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮 assert.equal(spoken, withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family)); assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/); });