import assert from "node:assert/strict"; import test from "node:test"; import { COLLECT_FLOW_BANNED_PHRASES, anchoredFollowups, collectionQuestionPool, inviteDeclined, moreCollectHint, preciseGapNarration, rangeNarrowHint, 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 } 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 pool = targetedCollectPool( layers, [educationStart, educationEnd, career], [], ["04:48", "05:07"], ); assert.equal(pool.length, 1); assert.equal(pool[0]?.kind, "targeted"); assert.equal(pool[0]?.key, "collect:targeted:relationship"); assert.match(pool[0]?.prompt ?? "", /能把 04:48 和 05:07 分开/); assert.doesNotMatch(pool[0]?.prompt ?? "", /1997|2012|推算/); assert.ok((pool[0]?.examples?.length ?? 0) >= 2); const followup = followupFromPoolItem(pool[0]!); assert.equal(stableFollowupQuestionId(followup), "collect:targeted:relationship"); assert.match(rangeNarrowHint(layers, [educationStart, educationEnd, career], [], ["04:48", "05:07"]), /还能再收窄:如果记得/); const declined = targetedCollectPool(layers, [educationStart, educationEnd, career], [{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship", }]); assert.equal(declined.length, 0); });