出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时 按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住 出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再 单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一 次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干 的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注 入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。 BUG-747~752 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
265 lines
9.0 KiB
TypeScript
265 lines
9.0 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import { applyHoldoutAnswer } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import { decideFromDossier, rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import { buildCaseInferenceState } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||
|
||
const COVERED_EVIDENCE = [
|
||
{
|
||
id: "e-edu",
|
||
status: "confirmed",
|
||
domain: "education",
|
||
datePrecision: "year",
|
||
occurredFrom: "2016-01-01",
|
||
occurredTo: null,
|
||
eventKind: "education_milestone",
|
||
},
|
||
{
|
||
id: "e-career-a",
|
||
status: "confirmed",
|
||
domain: "career",
|
||
datePrecision: "year",
|
||
occurredFrom: "2018-01-01",
|
||
occurredTo: null,
|
||
eventKind: "career_entry",
|
||
},
|
||
{
|
||
id: "e-career-b",
|
||
status: "confirmed",
|
||
domain: "career",
|
||
datePrecision: "year",
|
||
occurredFrom: "2020-01-01",
|
||
occurredTo: null,
|
||
eventKind: "career_change",
|
||
},
|
||
{
|
||
id: "e-rel",
|
||
status: "confirmed",
|
||
domain: "relationship",
|
||
datePrecision: "year",
|
||
occurredFrom: "2024-01-01",
|
||
occurredTo: null,
|
||
eventKind: "relationship_start",
|
||
},
|
||
{
|
||
id: "e-fam",
|
||
status: "confirmed",
|
||
domain: "family",
|
||
datePrecision: "year",
|
||
occurredFrom: "2023-01-01",
|
||
occurredTo: null,
|
||
eventKind: "family_event",
|
||
},
|
||
{
|
||
id: "e-occ",
|
||
status: "confirmed",
|
||
domain: "occupation",
|
||
datePrecision: "unknown",
|
||
occurredFrom: null,
|
||
occurredTo: null,
|
||
eventKind: "occupation_note",
|
||
},
|
||
{
|
||
id: "e-fin",
|
||
status: "confirmed",
|
||
domain: "finance",
|
||
datePrecision: "year",
|
||
occurredFrom: "2017-01-01",
|
||
occurredTo: null,
|
||
eventKind: "income_change",
|
||
},
|
||
{
|
||
id: "e-reloc",
|
||
status: "confirmed",
|
||
domain: "relocation",
|
||
datePrecision: "year",
|
||
occurredFrom: "2019-01-01",
|
||
occurredTo: null,
|
||
eventKind: "home_change",
|
||
},
|
||
{
|
||
id: "e-health",
|
||
status: "confirmed",
|
||
domain: "health_pressure",
|
||
datePrecision: "year",
|
||
occurredFrom: "2021-01-01",
|
||
occurredTo: null,
|
||
eventKind: "self_health_event",
|
||
},
|
||
] as const;
|
||
|
||
function producedDossier(
|
||
evidence: readonly Readonly<{
|
||
id: string;
|
||
status: string;
|
||
domain: string;
|
||
datePrecision: string;
|
||
occurredFrom: string | null;
|
||
occurredTo: string | null;
|
||
eventKind?: string | null;
|
||
}>[],
|
||
options: { previous?: InferenceState | null } = {},
|
||
) {
|
||
const state = buildCaseInferenceState({
|
||
range: { start_time: "04:55", end_time: "05:02" },
|
||
candidates: [
|
||
{ candidateId: "05:02", time: "05:02", relativeSupport: 58 },
|
||
{ candidateId: "04:55", time: "04:55", relativeSupport: 42 },
|
||
],
|
||
evidence,
|
||
probes: [],
|
||
previous: options.previous,
|
||
});
|
||
const latest = {
|
||
resultId: "55555555-5555-4555-8555-555555555555",
|
||
candidates: state.candidates.map((item) => ({
|
||
candidateId: item.id,
|
||
time: item.time,
|
||
rank: item.rank,
|
||
relativeSupport: Math.round(item.posterior_score),
|
||
})),
|
||
representativeTime: state.representative_time,
|
||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
|
||
decisionReceipt: {
|
||
acceptance_allowed: true,
|
||
selection_allowed: true,
|
||
propose_allowed: true,
|
||
confirmation_allowed: false,
|
||
inference_state: state,
|
||
},
|
||
};
|
||
return {
|
||
state,
|
||
dossier: {
|
||
evidence,
|
||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||
latestResult: latest,
|
||
case: { acceptedTime: null },
|
||
},
|
||
};
|
||
}
|
||
|
||
function holdoutFollowup(dossier: ReturnType<typeof producedDossier>["dossier"]) {
|
||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||
return buildMethodFollowupPlan({
|
||
evidence: dossier.evidence,
|
||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||
sessionOutcome: "validate_holdout",
|
||
...catalog,
|
||
candidatesSeparated: true,
|
||
});
|
||
}
|
||
|
||
test("sticky holdout with unknown precision stays review-only", () => {
|
||
const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE);
|
||
const holdout = state.events.find((item) => item.usage === "holdout");
|
||
assert.ok(holdout);
|
||
assert.notEqual(holdout.year, null);
|
||
const datedDecision = decideFromDossier(dated);
|
||
// 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。
|
||
// 为什么:dated holdout 只挡确认门;覆盖完成即可代表性采用。
|
||
assert.equal(datedDecision.nextAction, "ready_to_adopt");
|
||
assert.equal(datedDecision.canAdopt, true);
|
||
assert.equal(datedDecision.canConfirmExactMinute, false);
|
||
|
||
const undated = COVERED_EVIDENCE.map((item) => (
|
||
item.id === holdout.id
|
||
? { ...item, datePrecision: "unknown", occurredFrom: null, occurredTo: null }
|
||
: item
|
||
));
|
||
const { state: sticky, dossier } = producedDossier(undated, { previous: state });
|
||
const stickyHoldout = sticky.events.find((item) => item.id === holdout.id);
|
||
assert.equal(stickyHoldout?.usage, "holdout");
|
||
assert.equal(stickyHoldout?.year, null);
|
||
assert.equal(stickyHoldout?.precision, "unknown");
|
||
|
||
const decision = decideFromDossier(dossier);
|
||
assert.notEqual(decision.nextAction, "ask_holdout_validation");
|
||
// 原值: offer_provisional_range / ready_to_adopt、adopt_representative、canAdopt=true
|
||
// 新值: ask_fact_collection、collect_evidence
|
||
// 原因: D5 + D6——holdout 变成无日期后这条线回到采集池,线没问完不交付;
|
||
// 题名要的「stays review-only」(不进 validated_range)仍成立;
|
||
// canAdopt 是引擎能力位,公开侧由 canOfferRange 收起(BUG-751)
|
||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||
assert.notEqual(decision.sessionOutcome, "validated_range");
|
||
assert.equal(decision.validated, false);
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
});
|
||
|
||
test("dated holdout asks validation with a renderable followup card", () => {
|
||
const { dossier } = producedDossier(COVERED_EVIDENCE);
|
||
const decision = decideFromDossier(dossier);
|
||
// 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。
|
||
// 为什么:默认决策不再用 holdout 挡住 provisional 采用;确认门仍关。
|
||
// holdout 卡片在 validate_holdout 会话里仍可渲染,供确认路径使用。
|
||
assert.equal(decision.nextAction, "ready_to_adopt");
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
|
||
const plan = holdoutFollowup(dossier);
|
||
// BUG-580: 账本已覆盖 holdout 候选领域时不再出盘外核对卡,直接交付。
|
||
assert.equal(plan.next_followup, null);
|
||
});
|
||
|
||
test("passed holdout is a validated range, not a unique minute", () => {
|
||
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
|
||
const passed = applyHoldoutAnswer(state, "yes");
|
||
const decided = decideFromDossier({
|
||
...dossier,
|
||
latestResult: {
|
||
...dossier.latestResult,
|
||
decisionReceipt: {
|
||
...dossier.latestResult.decisionReceipt,
|
||
inference_state: passed,
|
||
},
|
||
},
|
||
});
|
||
assert.equal(decided.sessionOutcome, "validated_range");
|
||
assert.equal(decided.canAdopt, true);
|
||
assert.equal(decided.validated, true);
|
||
assert.equal(decided.canConfirmExactMinute, false);
|
||
assert.notEqual(decided.nextAction, "ask_holdout_validation");
|
||
});
|
||
|
||
test("failed holdout keeps existing non-validated close or discriminate path", () => {
|
||
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
|
||
const failed = applyHoldoutAnswer(state, "no");
|
||
assert.equal(failed.holdout_passed, false);
|
||
const decided = decideFromDossier({
|
||
...dossier,
|
||
latestResult: {
|
||
...dossier.latestResult,
|
||
decisionReceipt: {
|
||
...dossier.latestResult.decisionReceipt,
|
||
inference_state: failed,
|
||
},
|
||
},
|
||
});
|
||
assert.equal(decided.holdoutValidation, "failed");
|
||
assert.notEqual(decided.nextAction, "ask_holdout_validation");
|
||
assert.notEqual(decided.sessionOutcome, "validated_range");
|
||
assert.equal(decided.validated, false);
|
||
assert.equal(decided.canConfirmExactMinute, false);
|
||
});
|
||
|
||
test("ask_holdout_validation is only returned when the same dossier can build a holdout followup", () => {
|
||
const fixtures = [
|
||
producedDossier(COVERED_EVIDENCE).dossier,
|
||
producedDossier(COVERED_EVIDENCE.filter((item) => item.domain !== "occupation")).dossier,
|
||
];
|
||
for (const dossier of fixtures) {
|
||
const decision = decideFromDossier(dossier);
|
||
if (decision.nextAction !== "ask_holdout_validation") continue;
|
||
const plan = holdoutFollowup(dossier);
|
||
assert.ok(plan.next_followup, "ask_holdout_validation requires a holdout followup");
|
||
assert.ok(plan.next_followup.choice_frame, "ask_holdout_validation requires a renderable card");
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
}
|
||
});
|