Files
Jyotisha/frontend/tests/rectification-range-offer-deadend.test.ts
T
Jesse_Chen aab3ad4b84
Independent Staging Quality Gate / validate (push) Successful in 7m44s
Independent Staging Quality Gate / publish (push) Successful in 1m48s
fix(rectification): keep choice stems visible and align overlay adopt with the engine
Walkthrough polish: fail-closed empty D9 prompts, stop asserting the next question is on screen, vary same-domain collect copy, switch to reverse-verify after adopt, and let coverage route interview without blocking can_adopt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:11:22 +08:00

650 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 and dated domains confirmed asks occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "occupation");
assert.equal(next?.choice_frame, null);
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation);
});
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);
});