import assert from "node:assert/strict"; import test from "node:test"; import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE, anchoredFollowups, collectionQuestionPool, inviteDeclined, moreCollectHint, pendingTargetedYearDomain, preciseGapNarration, rangeNarrowHint, remainingCandidatesLine, remainingSplitLayers, targetedCollectPool, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts"; import { isOccupationCollectFocus } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { followupFromPoolItem, targetedCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts"; const educationStart = { status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: "2016-09-30", eventKind: "education_start", summary: "上大学", } as const; const educationEnd = { status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2020-06-01", occurredTo: "2020-06-30", eventKind: "education_completion", summary: "毕业", } as const; const inviteDeclinedTopic = { target_domain: "other", status: "declined", intent: "collect_method_evidence", questionId: "collect:invite:more", target_kind: "invite_more", }; function assertNoBanned(text: string) { for (const phrase of COLLECT_FLOW_BANNED_PHRASES) { assert.equal(text.includes(phrase), false, phrase); } } test("invite stays first while it is producing, then drops after a decline", () => { const first = collectionQuestionPool([educationStart]); assert.equal(first[0]?.kind, "invite"); assert.equal(first[0]?.key, "collect:invite:more"); assert.match(first[0]?.prompt ?? "", /^还有吗?/); const still = collectionQuestionPool([educationStart, educationEnd]); assert.equal(still[0]?.kind, "invite"); assert.equal(inviteDeclined([inviteDeclinedTopic]), true); const afterDecline = collectionQuestionPool( [educationStart, educationEnd], [inviteDeclinedTopic], ); assert.notEqual(afterDecline[0]?.kind, "invite"); assert.equal(afterDecline.some((item) => item.kind === "invite"), false); }); test("education completion anchors to the graduation job question from the user year", () => { const asked = new Set(); const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked); assert.match(anchors[0]?.prompt ?? "", /2020 年毕业后第一份工作/); assert.doesNotMatch(anchors.map((item) => item.prompt).join("\n"), /年前后/); const declinedAnchor = collectionQuestionPool( [educationStart, educationEnd], [ inviteDeclinedTopic, { target_domain: "career", status: "declined", intent: "collect_method_evidence", questionId: "collect:anchor:education_completion:2020", target_kind: "anchor:education_completion:2020", }, ], ); assert.equal( declinedAnchor.some((item) => item.key === "collect:anchor:education_completion:2020"), false, ); }); test("precise gap names the recorded events and at least two uncovered kinds", () => { const gap = preciseGapNarration([educationStart, educationEnd], [inviteDeclinedTopic]); assert.match(gap, /现在记下的是/); assert.match(gap, /2016 年 9 月上大学/); assert.match(gap, /2020 年 6 月毕业/); assert.match(gap, /都是上学的事/); assert.match(gap, /就能开始筛/); const examples = (gap.match(/第一份工作|搬到别的城市|谈恋爱|家里添丁|生病受伤/g) ?? []).length; assert.ok(examples >= 2, gap); assertNoBanned(gap); }); test("more-collect hint after delivery never uses banned collect-flow phrases", () => { const hint = moreCollectHint([educationStart, educationEnd], [inviteDeclinedTopic]); assert.match(hint, /范围还能再收一截/); assertNoBanned(hint); }); test("generic occupation collect does not ask for a year", () => { assert.match(USER_COLLECT_QUESTION.occupation, /你平时主要做什么工作/); assert.doesNotMatch(USER_COLLECT_QUESTION.occupation, /哪年|哪一年|年份/); assert.doesNotMatch(USER_COLLECT_QUESTION_RETRY.occupation, /哪年|哪一年|年份/); }); test("graduation job follow-up is a career anchor, not occupation collect", () => { const asked = new Set(); const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked); const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020"); assert.ok(job); assert.equal(job?.domain, "career"); assert.match(job?.prompt ?? "", /2020 年毕业后第一份工作/); const followup = followupFromPoolItem(job!); assert.equal(followup.domain, "career"); const questionId = stableFollowupQuestionId(followup); assert.equal(questionId, "collect:anchor:education_completion:2020"); assert.equal(questionId.startsWith("collect:occupation:"), false); assert.equal(isOccupationCollectFocus({ intent: followup.intent, targetDomain: followup.domain, targetKind: followup.kind_hint, questionId, }), false); }); test("targeted collect names remaining split layers without inferred years", () => { const career = { status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "入职实习", } as const; const layers = remainingSplitLayers({ transitions: [ { layer: "d9", at: "04:52" }, { layer: "d10", at: "05:00" }, { layer: "d4", at: "05:00" }, { layer: "d12", at: "05:00" }, { layer: "d2", at: "05:00" }, { layer: "d11", at: "05:07" }, ], activeTimes: ["04:48", "04:53", "05:06", "05:07"], }); const evidence = [educationStart, educationEnd, career]; const splitTimes = ["04:48", "05:07"] as const; const pool = targetedCollectPool(layers, evidence, [], splitTimes, 4); // 原值: pool.length === 1,prompt 含「能把 04:48 和 05:07 分开」 // 新值: 剩余未覆盖线各一张存在卡;题干是「有没有」,剩余候选句走 remainingLine // 原因: BUG-661 / BUG-662 逐条点选 + 不再用范围端点当候选 assert.equal(pool.map((item) => item.domain).join(","), "relationship,relocation,family,finance"); assert.equal(pool[0]?.kind, "targeted"); assert.equal(pool[0]?.key, "collect:targeted:relationship"); assert.equal(pool[0]?.prompt, "结过婚或订过婚吗?"); assert.doesNotMatch(pool.map((item) => item.prompt).join("\n"), SPLIT_ENDPOINT_PHRASE); assert.doesNotMatch(pool[0]?.prompt ?? "", /1997|2012|推算/); assert.match(pool[0]?.remainingLine ?? "", /现在还剩 04:48–05:07 里 4 个候选,能把它们分开的是这几条线/); assert.ok((pool[0]?.examples?.length ?? 0) >= 2); const followup = targetedCollectFollowup(layers, evidence, [], splitTimes, 4); assert.equal(followup?.collection_key, "collect:targeted:relationship"); assert.equal(followup?.choice_frame?.scoring, false); assert.equal(followup?.choice_kind, "existence"); assert.match(followup?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 4 个候选/); assert.doesNotMatch(followup?.spoken_prompt ?? "", SPLIT_ENDPOINT_PHRASE); assert.equal(stableFollowupQuestionId(followupFromPoolItem(pool[0]!)), "collect:targeted:relationship"); assert.match(rangeNarrowHint(layers, evidence, [], splitTimes, 4), /现在还剩 04:48–05:07 里 4 个候选/); assert.match(rangeNarrowHint(layers, evidence, [], splitTimes, 4), /还能再收窄:如果记得/); const declinedRelationship = targetedCollectPool(layers, evidence, [{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship", }], splitTimes, 4); assert.ok(declinedRelationship.length >= 1); assert.notEqual(declinedRelationship[0]?.domain, "relationship"); assert.equal(pendingTargetedYearDomain([{ questionId: "collect:targeted:relationship", status: "resolved", target_kind: "targeted:relationship", }], evidence), "relationship"); }); test("remaining candidates line uses credible range when it differs from active minute span", () => { const splitTimes = ["04:50", "05:06"] as const; const credibleRange = ["04:48", "05:07"] as const; const examples = ["收入明显变过"]; assert.match( remainingCandidatesLine(splitTimes, 5, examples, credibleRange) ?? "", /现在还剩 04:48–05:07 里 5 个候选/, ); assert.match( remainingCandidatesLine(splitTimes, 5, examples, null) ?? "", /现在还剩 04:50–05:06 里 5 个候选/, ); const layers = remainingSplitLayers({ transitions: [ { layer: "d9", at: "04:52" }, { layer: "d2", at: "05:00" }, ], activeTimes: ["04:50", "04:53", "04:59", "05:03", "05:06"], }); const evidence = [educationStart, educationEnd, { status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "入职实习", }]; const followup = targetedCollectFollowup(layers, evidence, [], splitTimes, 5, credibleRange); assert.match(followup?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 5 个候选/); assert.match(rangeNarrowHint(layers, evidence, [], splitTimes, 5, credibleRange), /现在还剩 04:48–05:07 里 5 个候选/); });