fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Successful in 13m52s

Refresh persist failures no longer count as attempts. tied_first completes with a range before stillNeedNarrowing. Delivery narration and cards share DELIVERY_OUTCOMES. The question gap gets a delivered terminal so the unavailable copy does not appear after a range is given.
This commit is contained in:
jesse-ux
2026-09-14 15:41:04 +08:00
parent e59ef9fbfd
commit d5a8db0a4e
16 changed files with 644 additions and 9 deletions
@@ -305,6 +305,19 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
});
assert.equal(canShowRectificationSelectionCards(adopted), true);
assert.equal(canShowRectificationReadonlyRange(adopted), false);
const delivered = parseRectificationCandidateResult({
...camelCaseSnapshot,
session_outcome: "completed_with_range",
canAdopt: false,
can_adopt: false,
selectionAllowed: true,
credible_range: ["04:48", "05:07"],
});
// 原值: completed_with_range 且 canAdopt=false 不出卡、只出「再说一件」
// 新值: 出交付卡,不出采集范围行
// 原因: BUG-681 能说就能画
assert.equal(canShowRectificationSelectionCards(delivered), true);
assert.equal(canShowRectificationReadonlyRange(delivered), false);
});
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
@@ -0,0 +1,420 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DELIVERY_OUTCOMES,
decideRectification,
deliveryNarrationAllowed,
sessionOutcomeAllowsDelivery,
type DecisionSessionOutcome,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.ts";
import {
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
refreshDatedDiscriminatorPoolIfNeeded,
resetRefreshDiscriminatorProbesForTests,
setRefreshDiscriminatorProbesForTests,
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import {
RECTIFICATION_DELIVERED_COPY,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
OPEN_ENGINE_CAPABILITY_CEILING,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const TIED_CANDIDATES = [
{ candidateId: "88888888-8888-4888-8888-888888888881", time: "04:53", rank: 1, relativeSupport: 16 },
{ candidateId: "88888888-8888-4888-8888-888888888882", time: "05:00", rank: 2, relativeSupport: 16 },
{ candidateId: "88888888-8888-4888-8888-888888888883", time: "05:06", rank: 3, relativeSupport: 15 },
{ candidateId: "88888888-8888-4888-8888-888888888884", time: "04:51", rank: 4, relativeSupport: 14 },
{ candidateId: "88888888-8888-4888-8888-888888888885", time: "04:59", rank: 5, relativeSupport: 13 },
] as const;
const SNAPSHOT_BASE = {
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 },
{ candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 },
],
overallConfidence: "medium" as const,
selectionAllowed: true,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:48", "05:07"] as const,
decisionReceipt: {
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
},
};
function datedEvidenceRpc() {
return [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2016-09-01",
occurred_to: null,
date_precision: "month",
summary: "入职",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:06.000Z",
},
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_completion",
domain: "education",
occurred_from: "2012-06-01",
occurred_to: null,
date_precision: "month",
summary: "毕业",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:07.000Z",
},
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "family_event",
domain: "family",
occurred_from: "2018-03-01",
occurred_to: null,
date_precision: "month",
summary: "家里添丁",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:08.000Z",
},
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2020-05-01",
occurred_to: null,
date_precision: "month",
summary: "恋爱",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:09.000Z",
},
];
}
function tiedInference(extra: Record<string, unknown> = {}) {
const times = TIED_CANDIDATES.map((item) => item.time);
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:48", "05:07", times),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:48",
range_end: "05:07",
candidates: TIED_CANDIDATES.map((item) => ({
id: item.candidateId,
time: item.time,
cluster_range: ["04:48", "05:07"] as const,
prior_score: item.relativeSupport,
posterior_score: item.relativeSupport,
probability: item.relativeSupport === 16 ? 0.2592219508007118 : item.relativeSupport / 74,
status: "active" as const,
rank: item.rank,
strong_conflict_count: 0,
})),
events: datedEvidenceRpc().map((item, index) => ({
id: item.id,
domain: item.domain,
year: 2012 + index,
precision: "month" as const,
usage: "training" as const,
})),
probes: [],
answered_probes: [
{
probe_id: "probe-1",
semantic_key: "career.2016.test",
candidate_split_hash: "split-1",
answer_class: "yes" as const,
classified_from: "choice" as const,
},
],
rounds: [],
entropy: 1,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
refresh_attempts: [],
...extra,
};
}
function tiedRpcDossier() {
const evidence = datedEvidenceRpc();
return dossierFixture({
evidenceCount: 4,
evidence,
latestResult: candidateSnapshotFixture({
representativeTime: "04:53",
selectionAllowed: true,
candidates: TIED_CANDIDATES.map((item) => ({
candidate_id: item.candidateId,
rank: item.rank,
time: item.time,
relative_support: item.relativeSupport,
tied_minute_count: 2,
})),
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: tiedInference(),
},
}),
});
}
test("tied first place delivers a range even when narrowing flags are still open", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: TIED_CANDIDATES.map((item) => ({ time: item.time, score: item.relativeSupport })),
discriminatorProbe: null,
holdoutValidation: "unavailable",
datedEventCount: 3,
datedDomainCount: 3,
refreshExhausted: false,
targetedCollectExhausted: false,
});
assert.equal(decision.stopReason, "tied_first");
assert.equal(decision.canOfferRange, true);
assert.equal(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("refresh persist failure is not recorded as an attempt", async () => {
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, candidates: [] },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: 1,
}));
const rpc = tiedRpcDossier();
const dossier = parseV9CaseDossier(rpc);
assert.ok(dossier);
const warnings: string[] = [];
const original = console.warn;
console.warn = (value: unknown) => {
warnings.push(String(value));
};
try {
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_inference_transition: () => {
throw new Error("candidate_state_inconsistent");
},
}).client,
userId: USER_ID,
caseId: CASE_ID,
dossier: dossier!,
hasDatedProbe: false,
});
assert.equal(refreshed.attemptRecorded, false);
assert.equal(refreshed.refreshed, false);
assert.ok(warnings.some((line) => line.includes("rectification_refresh_attempt_persist_failed")));
const before = decideFromDossier(dossier!, { birthDate: "1997-08-08" });
const after = decideFromDossier(refreshed.dossier, { birthDate: "1997-08-08" });
assert.equal(after.sessionOutcome, before.sessionOutcome);
assert.equal(after.canOfferRange, before.canOfferRange);
assert.equal(after.stopReason, before.stopReason);
} finally {
console.warn = original;
resetRefreshDiscriminatorProbesForTests();
}
});
test("POST idle persist and GET decideFromDossier agree on a first-place tie", async () => {
const rpc = tiedRpcDossier();
const dossier = parseV9CaseDossier(rpc);
assert.ok(dossier);
const getDecision = decideFromDossier(dossier!, { birthDate: "1997-08-08", snapshotCurrent: true });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpc,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_inference_transition: () => {
throw new Error("candidate_state_inconsistent");
},
set_agentic_rectification_conversation_focus: (_fn, args) => {
throw new Error(`must not persist ${String(args.p_question_id)}`);
},
append_agentic_rectification_turn: () => ({
turn_id: "33333333-3333-4333-8333-333333333333",
idempotent: false,
}),
finalize_agentic_rectification_turn: () => ({
turn_id: "33333333-3333-4333-8333-333333333333",
status: "completed",
idempotent: false,
}),
get_agentic_rectification_turn_receipt: () => null,
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome);
const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration);
assert.equal(getDecision.stopReason, "tied_first");
assert.equal(getDelivers, true);
assert.equal(postDelivers, getDelivers);
});
test("completed_with_range with selection allowed shows the delivery card, not the collect range line", () => {
const result = parseRectificationCandidateResult({
...SNAPSHOT_BASE,
session_outcome: "completed_with_range",
selectionAllowed: true,
canAdopt: false,
});
assert.equal(canShowRectificationSelectionCards(result), true);
assert.equal(canShowRectificationReadonlyRange(result), false);
});
test("delivery narration and delivery cards stay true together for every outcome", () => {
const outcomes: DecisionSessionOutcome[] = [
"collect_evidence",
"compare_blocks",
"widen_window",
"discriminate_candidates",
"validate_holdout",
"provisional_range",
"provisional_range_user_stopped",
"completed_with_range",
"validated_range",
"exact_minute_confirmed",
"adopt_representative",
"awaiting_confirmation",
];
for (const outcome of outcomes) {
const adopt = outcome === "adopt_representative"
|| outcome === "provisional_range_user_stopped"
|| outcome === "awaiting_confirmation"
|| outcome === "validated_range";
const nextAction = outcome === "provisional_range"
? "offer_provisional_range"
: outcome === "completed_with_range"
? "complete_with_range"
: "ask_fact_collection";
const narrate = deliveryNarrationAllowed({
canAdopt: adopt,
sessionOutcome: outcome,
nextAction,
selectionAllowed: true,
});
const result = parseRectificationCandidateResult({
...SNAPSHOT_BASE,
session_outcome: outcome,
canAdopt: adopt,
selectionAllowed: true,
});
const cards = canShowRectificationSelectionCards(result);
if (narrate) {
assert.equal(cards, true, outcome);
}
if (DELIVERY_OUTCOMES.has(outcome) && outcome !== "completed_with_range" && outcome !== "provisional_range") {
assert.equal(narrate, true, outcome);
}
}
});
test("a delivered tie is not the unavailable repair gap", () => {
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "collect_evidence",
stopReason: "tied_first",
}),
"delivered",
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"delivered",
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: true,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
}),
"unavailable",
);
assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
});
@@ -240,6 +240,15 @@ test("deliveryNarrationAllowed requires public can_adopt or a selectable range o
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 () => {
@@ -140,6 +140,26 @@ test("question gap: a persisted focus question is shown instead of preparing", (
assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
});
test("question gap: a delivered range is not the unavailable repair path", () => {
assert.equal(
rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"delivered",
);
assert.equal(
rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
stopReason: "tied_first",
}),
"delivered",
);
assert.equal(rectificationQuestionRetryActive("delivered"), false);
});
test("question gap: a choice question without a card is unavailable, not collect waiting", () => {
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);