fix(rectification): narrate the credible range and stop scoring periods by length (BUG-569–570)

Choice copy was comparing the search window, so every answer said the range had not changed. Block-scan summed raw scores, so longer afternoon windows won before any evidence difference.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-07 10:03:28 +08:00
co-authored by Cursor
parent d6ee8cbb26
commit 517df002b5
17 changed files with 312 additions and 46 deletions
@@ -150,6 +150,13 @@ test("collect prompt skip no longer writes a second assistant turn", () => {
assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/);
});
test("probe explain tracks use the shared Vimshottari / Narayana labels", () => {
const explain = readFileSync(new URL("../src/lib/rectification-agentic/v9/probe-explain.ts", import.meta.url), "utf8");
const visible = listUserVisibleCopy().join("\n");
assert.doesNotMatch(explain, /毗湿奴多利|那罗延/);
assert.doesNotMatch(visible, /毗湿奴多利|那罗延/);
});
test("E-2 display copy keeps engine meaning out of instruction tone", () => {
const dasha = engineMeaningToDisplayCopy(
"主限更偏向 05:00,分盘大运更偏向 04:54。冲突时不能按更高把握收口。",
@@ -28,6 +28,7 @@ import {
isIncompleteRunBanner,
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
@@ -193,6 +194,68 @@ function inferenceState() {
});
}
function rangeNarrationInference(leadSupport: number, trailSupport: number) {
return buildInferenceState({
range_start: "04:31",
range_end: "05:07",
candidates: [
{ id: "04:31", time: "04:31", relative_support: leadSupport },
{ id: "05:07", time: "05:07", relative_support: trailSupport },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "year" },
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
{ id: "e4", domain: "family", year: 2023, precision: "year" },
],
probes: [{
id: "p-cd",
semantic_key: "career.2015",
candidate_split_hash: "04:31|05:07",
domain: "career",
year: 2015,
question: "2016 年前后有没有高考或重要考试发挥失常?",
candidate_ids: ["04:31", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:31"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["04:31"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
}],
});
}
function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>) {
return dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: { inference_state: inference },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
expectedAnswerSchema: {
choice: {
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
option_a: "是,大概就在那段时间",
option_b: "有类似,但年份不对或不够重大",
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
options: [
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
{ key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" },
{ key: "C", label: "没有明显发生", answer_class: "no" },
{ key: "D", label: "不记得 / 不确定", answer_class: "unsure" },
],
},
probe_id: "p-cd",
semantic_key: "career.2015",
},
}),
}),
});
}
function choiceDossier() {
const inference = inferenceState();
const snapshot = candidateSnapshotFixture({
@@ -1207,8 +1270,10 @@ test("a scoring choice narrates cluster movement and range change", () => {
{ range: ["04:31", "04:39"], delta: 2 },
{ range: ["05:00", "05:07"], delta: -2 },
],
rangeBefore: ["04:31", "05:07"],
rangeAfter: ["04:31", "04:39"],
// 旧:rangeBefore/rangeAfter 直喂两个不同搜索窗口 → 新:入参改名 credibleBefore/credibleAfter,仍测纯函数拼句
// 原因:直喂拦不住 applyRectificationChoice 把 range_start/range_end 当成范围
credibleBefore: ["04:31", "05:07"],
credibleAfter: ["04:31", "04:39"],
});
assert.match(narrowed, /领先/);
assert.match(narrowed, /落后/);
@@ -1218,8 +1283,8 @@ test("a scoring choice narrates cluster movement and range change", () => {
scoring: true,
appliedInference: true,
deltasByCluster: [{ range: ["04:31", "04:39"], delta: 1 }],
rangeBefore: ["04:31", "04:39"],
rangeAfter: ["04:31", "04:39"],
credibleBefore: ["04:31", "04:39"],
credibleAfter: ["04:31", "04:39"],
});
assert.match(unchanged, /范围没变/);
const unsure = composeChoiceNarration({
@@ -1227,12 +1292,80 @@ test("a scoring choice narrates cluster movement and range change", () => {
scoring: true,
appliedInference: true,
answerClass: "unsure",
rangeBefore: ["04:31", "04:39"],
rangeAfter: ["04:31", "04:39"],
credibleBefore: ["04:31", "04:39"],
credibleAfter: ["04:31", "04:39"],
});
assert.equal(unsure, "已记录。这题先不计分,换一件事问。");
});
test("answering A narrates the credible range even when the search window stays put", async () => {
const previous = rangeNarrationInference(10, 3);
assert.deepEqual([previous.range_start, previous.range_end], ["04:31", "05:07"]);
assert.deepEqual(previous.credible_range, ["04:31", "05:07"]);
const reduced = applyChoiceWithoutEvidence(previous, {
choiceKey: "A",
schema: {
probe_id: "p-cd",
semantic_key: "career.2015",
options: [
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
],
},
});
assert.equal(reduced.applied, true);
assert.deepEqual([reduced.state.range_start, reduced.state.range_end], ["04:31", "05:07"]);
assert.deepEqual(reduced.state.credible_range, ["04:31", "04:31"]);
const applied = await applyRectificationChoice(persistChoiceAccounting(rangeNarrationDossier(previous)).client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: "p-cd",
optionId: "A",
expectedRevision: previous.revision,
});
assert.match(applied.narration, /范围从 04:3105:07 收到 04:3104:31/);
assert.doesNotMatch(applied.narration, /范围没变/);
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
assert.match(source, /credibleBefore:\s*previous\.credible_range/);
assert.match(source, /credibleAfter:\s*applied\.state\.credible_range/);
assert.doesNotMatch(source, /rangeBefore:\s*\[previous\.range_start/);
});
test("answering A says the range did not change when the credible range stays put", async () => {
const previous = rangeNarrationInference(10, 10);
assert.deepEqual(previous.credible_range, ["04:31", "05:07"]);
const reduced = applyChoiceWithoutEvidence(previous, {
choiceKey: "A",
schema: {
probe_id: "p-cd",
semantic_key: "career.2015",
options: [
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
],
},
});
assert.deepEqual([reduced.state.range_start, reduced.state.range_end], ["04:31", "05:07"]);
assert.deepEqual(reduced.state.credible_range, ["04:31", "05:07"]);
const applied = await applyRectificationChoice(persistChoiceAccounting(rangeNarrationDossier(previous)).client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: "p-cd",
optionId: "A",
expectedRevision: previous.revision,
});
assert.match(applied.narration, /范围没变/);
assert.doesNotMatch(applied.narration, /范围从 /);
});
test("the public agent route treats structured choice as a non-model command", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const start = route.indexOf("if (isStructuredChoice)");
@@ -130,6 +130,9 @@ test("choice frames ask one biographical event from a server probe, not competin
assert.equal(mergeChoiceCard(frame, null), null);
assert.match(frame.why_user, /2016 年前后/);
assert.match(frame.why_user, /分成两组/);
assert.match(frame.why_user, /Vimshottari/);
assert.match(frame.why_user, /Narayana/);
assert.doesNotMatch(frame.why_user, /毗湿奴多利|那罗延/);
assert.match(frame.answer_impact.A, /05:00/);
assert.match(frame.answer_impact.A, /领先/);
assert.equal(frame.answer_impact.D, "不计分,换一题");