Files
Jyotisha/frontend/tests/rectification-range-offer-deadend.test.ts
T
Jesse_Chen 329f8a2109
Independent Staging Quality Gate / validate (push) Successful in 11m34s
Independent Staging Quality Gate / publish (push) Successful in 1m56s
fix(rectification): pin the adopt card and drop casual adopt copy
The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:03:19 +08:00

653 lines
24 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 { readFileSync } from "node:fs";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import {
inspectDiscriminatorProbes,
buildCandidateContrastPacket,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import {
decideRectification,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
REPRESENTATIVE_MINUTE_DISCLAIMER,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
exhaustionSpokenCollectFollowup,
projectRectificationChoiceCard,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
OPEN_ENGINE_CAPABILITY_CEILING,
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_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 },
];
const TIMES = [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
] as const;
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
const SCORES: Record<string, number> = {
"04:47": 16,
"04:51": 20,
"04:53": 16,
"04:59": 10,
"05:00": 4,
"05:07": 3,
"05:12": 2,
"05:14": 1,
"05:15": 1,
};
const PROBABILITY: Record<string, number> = {
"04:47": 0.25,
"04:51": 0.4,
"04:53": 0.25,
"04:59": 0.1,
};
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020-04-01 career_entry",
},
{
id: "e-career-exit",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020-10-01 career_exit",
},
{
id: "e-rel-start",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "month" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "2024-05-01 relationship_start",
},
{
id: "e-rel-end",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
summary: "2024-08-08 relationship_end",
},
] as const;
const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历";
function existenceProbe(input: {
key: string;
year: number;
question: string;
gain: number;
source: string;
yes: readonly string[];
no: readonly string[];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: "career",
year: input.year,
question: input.question,
candidate_ids: [...new Set([...input.yes, ...input.no])],
expected_outcomes: [
{ answer_class: "yes", supports: input.yes, conflicts: input.no },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: input.no, conflicts: input.yes },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: input.gain,
source: input.source,
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.1,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.05,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_2023_05 = existenceProbe({
key: "career.2023.05.dasha_boundary",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作?",
gain: 0.9,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
});
const RELOCATION_2015_05: ConflictProbe = {
...existenceProbe({
key: "relocation.2015.05.dasha_boundary",
year: 2015,
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
gain: 0.7,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
}),
domain: "relocation",
};
/** Live remaining probe: splits after/before 05:00. Zero split on 04:4704:59. */
const CAREER_2024_04 = existenceProbe({
key: "career.2024.04.dasha_boundary",
year: 2024,
question: "2024 年 4 月前后有没有入职或换工作?",
gain: 1.1712,
source: "dasha_boundary",
yes: ["05:00", "05:07", "05:12", "05:14", "05:15"],
no: ["04:47", "04:51", "04:53", "04:59"],
});
/** Live remaining probe: isolates 05:15. Zero split on 04:4704:59. */
const CAREER_2023_ACTIVATION = existenceProbe({
key: "career.2023.dasha_activation",
year: 2023,
question: "2023 年前后大运有没有启动?",
gain: 0.56,
source: "dasha_activation",
yes: ["05:15"],
no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
});
/** P1 fixture: dated career probe that still splits the active four minutes. */
const CAREER_ACTIVE_SPLIT = existenceProbe({
key: "career.2022.dasha_boundary",
year: 2022,
question: "2022 年前后有没有入职或换工作?",
gain: 1.4,
source: "dasha_boundary",
yes: ["04:47", "04:51"],
no: ["04:53", "04:59"],
});
function uuidAt(index: number): string {
return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`;
}
function liveState(remaining: readonly ConflictProbe[]): InferenceState {
const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining];
const rankedActive = [...ACTIVE].sort((left, right) => (
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|| left.localeCompare(right)
));
const candidates = TIMES.map((time, index) => {
const eliminated = ELIMINATED.has(time);
const activeRank = (rankedActive as readonly string[]).indexOf(time);
return {
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
status: eliminated ? "eliminated" as const : "active" as const,
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
strong_conflict_count: eliminated ? 3 : 0,
};
});
const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "no" as const,
classified_from: "choice" as const,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 5,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const },
],
probes,
answered_probes: answered,
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "04:51",
credible_range: ["04:47", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "live inference fixture must pass asInferenceState");
return loaded;
}
function liveDossier(
remaining: readonly ConflictProbe[],
extra: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
status?: string;
eventProbes?: boolean;
} = {},
): DecisionDossier {
const state = liveState(remaining);
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
...(extra.eventProbes
? {
discriminating_event_probes: remaining.map((probe) => ({
year: probe.year,
year_label: `${probe.year} 年前后`,
domain: probe.domain,
event_family: "入职、换工作或职责加重",
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
}
: {}),
},
},
case: { acceptedTime: null, status: extra.status },
};
}
function snapshotCandidates() {
return TIMES.map((time, index) => ({
candidate_id: uuidAt(index),
rank: index + 1,
time,
relative_support: Math.round(SCORES[time] ?? 0),
tied_minute_count: 1,
}));
}
function rpcDossier(decision: DecisionDossier) {
const evidence = EVIDENCE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
candidates: snapshotCandidates(),
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
...(decision.latestResult?.decisionReceipt ?? {}),
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-08-30T00:00:00.000Z",
},
});
}
test("skill version stays 10.0.14 for the range-offer dead-end fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts";
const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts";
const route = "../src/app/api/rectification/agent/route.ts";
for (const relative of [decision, answer, route]) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
assert.doesNotMatch(source, new RegExp(DUAL_EXIT));
assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/);
assert.match(source, /nonConvergingRangeNarration/);
}
const text = nonConvergingRangeNarration({
credibleRange: ["04:47", "04:53"],
representativeTime: "04:51",
});
assert.match(text, /04:4704:53/);
assert.match(text, /04:51/);
assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.doesNotMatch(text, /可以先按当前区间看盘/);
});
test("zero-split among active is dropped with an explicit reason, not kept silent", () => {
const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE);
const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE);
const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE);
assert.equal(after.splits, false);
assert.equal(activation.splits, false);
assert.equal(split.splits, true);
const packet = buildCandidateContrastPacket({
candidateSetVersion: candidateSetId("04:47", "05:15", TIMES),
engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
domain: probe.domain,
year: probe.year,
user_meaning: probe.question,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
providedDomains: ["career", "relationship"],
candidateTimes: [...TIMES],
});
assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true);
const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE });
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => {
const dossier = liveDossier([CAREER_ACTIVE_SPLIT]);
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(fromDossier.nextAction, "ask_candidate_discriminator");
assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
assert.equal(isNonConvergingRangeOffer(fromDossier), false);
const after = decideAfterInferenceChange({
dossier,
state: liveState([CAREER_ACTIVE_SPLIT]),
userStopped: false,
birthDate: "1997-08-08",
});
assert.equal(after.nextAction, "ask_candidate_discriminator");
assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
});
test("live remaining probes with zero active split drop and open provisional adopt", () => {
const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION];
const dossier = liveDossier(remaining);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(isNonConvergingRangeOffer(decision), false);
assert.equal(decision.canAdopt, true);
assert.equal(decision.selectionAllowed, true);
assert.equal(decision.canConfirmExactMinute, false);
assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]);
assert.equal(decision.representativeTime, "04:51");
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("adoptable range offer does not persist another spoken collect", async () => {
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.canAdopt, true);
assert.equal(isNonConvergingRangeOffer(decision), false);
const card = projectRectificationChoiceCard({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
selectionAllowed: decision.selectionAllowed,
});
assert.equal(card, null);
const collect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
assert.equal(collect?.domain, "education");
assert.equal(collect?.intent, "collect_method_evidence");
assert.equal(collect?.choice_frame, null);
const spoken = spokenFollowupForUser(collect);
assert.ok(spoken);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: () => {
throw new Error("adoptable offer must not persist another question");
},
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(idle.persisted, false);
assert.equal(idle.choiceReady, false);
assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/);
assert.match(idle.hostNarration ?? "", /04:4704:53/);
assert.match(idle.hostNarration ?? "", /04:51/);
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus, undefined);
});
test("userStopped completes with a review-only provisional range", () => {
const stopped = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
userStopped: true,
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
});
assert.equal(stopped.sessionOutcome, "adopt_representative");
// 原断言 sessionOutcome=provisional_range_user_stopped 且 canAdopt=false
// → 新断言 adopt_representative 且 canAdopt=true。
// 用户停止结束追问;coverage 不再挡代表性采用。确认门仍关。
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canOfferRange, true);
assert.equal(stopped.canConfirmExactMinute, false);
assert.equal(isNonConvergingRangeOffer(stopped), false);
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
// 家人已拒答、带日期层已覆盖、引擎已放行时,停止追问仍应交付代表性采用卡。
assert.equal(fromDossier.sessionOutcome, "adopt_representative");
assert.equal(fromDossier.canAdopt, true);
assert.equal(fromDossier.canConfirmExactMinute, false);
assert.equal(fromDossier.completionStatus, "provisional_range_user_stopped");
});
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /persistExhaustionCollect/);
});
function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) {
return {
status: "confirmed" as const,
domain,
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
occurredTo: null,
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
};
}
test("exhaustion after family declined still fills remaining dated domains before occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
// 原值: occupation / USER_COLLECT_QUESTION.occupation
// 新值: relocation / USER_COLLECT_QUESTION.relocation
// 原因: datedCollectOrder 把搬家、健康放在职业之前,穷尽路径与计划主流程共用。
assert.equal(next?.domain, "relocation");
assert.equal(next?.choice_frame, null);
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.relocation);
});
test("exhaustion generic fallback uses domain other, not unknown", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
datedCollectEvidence("relocation", "2022"),
datedCollectEvidence("health_pressure", "2021"),
datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "other");
assert.equal(next?.intent, "collect_method_evidence");
assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence");
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.other);
});