Files
Jyotisha/frontend/tests/rectification-superseded-focus.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

319 lines
12 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 {
deliveryNarrationAllowed,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { exhaustionSpokenCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIXES,
collectFocusRetryQuestionIds,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
const BASE_QUESTION = "collect:relationship:collect_method_evidence";
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
...overrides,
};
}
function focusRowFromArgs(args: Record<string, unknown>) {
return {
id: "abababab-abab-4bab-8bab-abababababab",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-10T00:00:00.000Z",
resolved_at: null,
idempotent: false,
};
}
function datedEvidence() {
return [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2022-06-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2021-11-01", occurredTo: null, eventKind: "health_episode" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
] as const;
}
function rpcEvidence(item: ReturnType<typeof datedEvidence>[number]) {
return {
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.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-10T00:00:00.000Z",
};
}
function relationshipOpenDossier() {
const ledger = datedEvidence();
const evidence = ledger.map((item) => rpcEvidence(item));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(ledger as never),
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-10T00:00:00.000Z",
},
});
}
test("collect focus retries next then next2 then next3 before duplicate_focus", async () => {
assert.deepEqual(COLLECT_FOCUS_RETRY_SUFFIXES, ["next", "next2", "next3"]);
assert.deepEqual(
collectFocusRetryQuestionIds(BASE_QUESTION),
[`${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`, `${BASE_QUESTION}:next3`],
);
const blocked = new Set([BASE_QUESTION, `${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`]);
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
const id = String(args.p_question_id);
if (blocked.has(id)) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
});
test("collect focus four collisions return duplicate_focus", async () => {
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
writes += 1;
throw new Error("agentic_rectification_focus_idempotency_conflict");
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "duplicate_focus");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
});
test("collect_retry only changes copy, not whether a suffix is tried", async () => {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
if (args.p_question_id === BASE_QUESTION) {
throw new Error("agentic_rectification_focus_idempotency_conflict");
}
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({ collect_retry: true }),
});
assert.equal(persisted.status, "created");
assert.equal(persisted.questionId, `${BASE_QUESTION}:next`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
});
test("probe question ids do not receive collect suffixes", () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "education",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: "education:2016",
candidate_split_hash: "education:2016",
};
const questionId = stableFollowupQuestionId(followup);
assert.match(questionId, /^probe:/);
assert.equal(questionId.includes(":next"), false);
});
test("deliveryNarrationAllowed requires public can_adopt or a selectable range offer", () => {
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "collect_evidence",
nextAction: "ask_fact_collection",
selectionAllowed: true,
}), false);
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "adopt_representative",
nextAction: "ready_to_adopt",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "provisional_range",
nextAction: "offer_provisional_range",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "collect_evidence",
nextAction: "offer_provisional_range",
selectionAllowed: false,
}), false);
// 原值: completed_with_range 且 canAdopt=false 不能播交付
// 新值: selectionAllowed 且 outcome 在 DELIVERY_OUTCOMES 即可播
// 原因: BUG-681 能说就能画,completed_with_range / provisional_range 与卡片同真同假
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "completed_with_range",
nextAction: "complete_with_range",
selectionAllowed: true,
}), true);
});
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
const raw = relationshipOpenDossier();
const remaining = exhaustionSpokenCollectFollowup({
evidence: datedEvidence(),
declinedTopics: [],
});
// 原值: leftover 感情采集
// 新值: 训练门开后 leftover dated collect 为 null
// 原因: BUG-648
assert.equal(remaining, null);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "11111111-1111-4111-8111-111111111111",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-10T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.doesNotMatch(idle.hostNarration ?? "", /领域不限|做不了|材料不够/);
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
// 原值: p_target_domain 不得是 relationship / finance
// 新值: 写的是定向感情线(本夹具就叫 relationshipOpenDossier,这条线是开着的)
// 原因: D5——七条线全部轮到,感情线仍未覆盖就该被问;本题真正的断言是
// 上一行「不得播交付文案」(BUG-751)
// 原值(2): 写定向感情线焦点 collect:targeted|guided:*
// 新值(2): 不写任何焦点,旁白非空(门开后没有「最后一道采集题」,题名的前提不再出现;禁词断言照旧)
// 原因(2): 门开后不再口述采集(BUG-1084,2026-09-29 D1)
assert.equal(setFocus, undefined);
assert.ok((idle.hostNarration ?? "").trim());
});
test("delivery copy is gated at the three persist sites", () => {
const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
const decision = readFileSync(
new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url),
"utf8",
);
assert.match(decision, /export function deliveryNarrationAllowed/);
assert.match(answer, /deliveryNarrationAllowed\(decision, nextAction\.type\)/);
assert.match(answer, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/);
assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
});