Files
Jyotisha/frontend/tests/rectification-collection-question-pool.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

255 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);池本身的规则仍在门前生效,用门前夹具继续锁住
// R1(BUG-1193):门开夹具补第 4 件 / 第 3 域(搬家),保持「门开后池为空」原场景。
const relocation = {
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2022-07-01",
occurredTo: null,
eventKind: "relocation",
summary: "搬到外地",
} as const;
assert.deepEqual(targetedCollectPool(layers, [educationStart, educationEnd, career, relocation], [], ["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 个候选/);
});