Files
Jyotisha/frontend/tests/rectification-collection-question-pool.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

245 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
TARGETED_EXISTENCE_PROMPT,
} 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<string>();
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<string>();
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"],
});
// 原值: evidence = [educationStart, educationEnd, career](3 件 2 类,训练门已开)
// 新值: 毕业那件改成日期不明(不进训练门),门未开、覆盖的领域不变;门开的原夹具另断言池为空
// 原因: 门开后定向池恒空(BUG-1084,2026-09-29 D1);池本身的规则仍在门前生效,用门前夹具继续锁住
assert.deepEqual(targetedCollectPool(layers, [educationStart, educationEnd, career], [], ["04:48", "05:07"], 4), []);
const evidence = [educationStart, { ...educationEnd, datePrecision: "unknown", occurredFrom: null, occurredTo: null }, 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 逐条点选 + 不再用范围端点当候选
// 原值: "relationship,relocation,family,finance"
// 新值: 加上 health_pressure
// 原因: D5 remainingTargetedDomains 不再按分盘层剔除,七条线都要轮到
assert.equal(pool.map((item) => item.domain).join(","), "relationship,relocation,family,finance,health_pressure");
assert.equal(pool[0]?.kind, "targeted");
assert.equal(pool[0]?.key, "collect:targeted:relationship");
// 原值: "结过婚或订过婚吗?"
// 新值: 领域全称问法,哪一年都算
// 原因: D5 存在性问题必须问整个领域
assert.equal(pool[0]?.prompt, TARGETED_EXISTENCE_PROMPT.relationship);
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"],
});
// 原值: evidence 含日期完整的 educationEnd(训练门已开)
// 新值: educationEnd 改成日期不明,门未开
// 原因: 门开后定向题为 null(BUG-1084,2026-09-29 D1);本条锁的是剩余候选句取可信范围,门前仍适用
const evidence = [educationStart, { ...educationEnd, datePrecision: "unknown", occurredFrom: null, occurredTo: null }, {
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 个候选/);
});