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
@@ -592,8 +592,8 @@ export async function applyRectificationChoice(
appliedInference: persistable && scoring,
answerClass,
deltasByCluster: clusterScoreDeltas(previous.candidates, appliedScoreDeltas),
rangeBefore: [previous.range_start, previous.range_end],
rangeAfter: [applied.state.range_start, applied.state.range_end],
credibleBefore: previous.credible_range,
credibleAfter: applied.state.credible_range,
});
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
@@ -102,8 +102,8 @@ export function composeChoiceNarration(input: {
appliedInference: boolean;
answerClass?: AnswerClass | null;
deltasByCluster?: readonly ClusterScoreDelta[];
rangeBefore?: readonly [string, string] | null;
rangeAfter?: readonly [string, string] | null;
credibleBefore?: readonly [string, string] | null;
credibleAfter?: readonly [string, string] | null;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
@@ -119,7 +119,7 @@ export function composeChoiceNarration(input: {
}
if (input.appliedInference) {
const movement = explainScoreMovement(input.deltasByCluster ?? []);
const range = explainRangeChange(input.rangeBefore, input.rangeAfter);
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
if (movement || range) {
return ["已记录你的选择", movement, range].filter(Boolean).join("。") + "。";
}
@@ -3,6 +3,7 @@
* The model writes the stem; this module writes "why" and "what answering does".
*/
import { publicRectificationMethodLabel } from "../../rectification-varga-sentence.ts";
import type { AnswerClass } from "../core/types.ts";
import type { DiscriminatingEventProbe, ProbeExpectedOutcome } from "./refinement-packet.ts";
@@ -31,11 +32,6 @@ export type ProbeUserExplain = Readonly<{
answer_impact: ProbeAnswerImpact;
}>;
const TRACK_LABEL: Readonly<Record<string, string>> = {
vimshottari: "毗湿奴多利",
narayana: "那罗延",
};
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export const PROBE_EXPLAIN_COPY = {
@@ -91,7 +87,10 @@ function rangesForTimes(
}
function trackLabels(tracks: readonly string[] | undefined): string {
const labels = [...new Set((tracks ?? []).map((track) => TRACK_LABEL[track]).filter(Boolean))];
const labels = [...new Set((tracks ?? []).flatMap((track) => {
const label = publicRectificationMethodLabel(track);
return label ? [label] : [];
}))];
return labels.join("、");
}
@@ -22,11 +22,17 @@ export const PUBLIC_RECTIFICATION_METHOD_LABELS: Readonly<Record<PublicRectifica
"functional-benefic-malefic": "本命功能吉凶星",
};
export function publicRectificationMethodLabel(methodOrTrack: string): string | null {
const direct = PUBLIC_RECTIFICATION_METHOD_LABELS[methodOrTrack as PublicRectificationMethod];
if (direct) return direct;
return PUBLIC_RECTIFICATION_METHOD_LABELS[`${methodOrTrack}-dasha` as PublicRectificationMethod] ?? null;
}
export function vargaSentenceFromMethods(
methods: readonly string[] | null | undefined,
): string | null {
const labels = [...new Set((methods ?? []).flatMap((method) => {
const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method as PublicRectificationMethod];
const label = publicRectificationMethodLabel(method);
return label ? [label] : [];
}))];
if (labels.length === 0) return null;
@@ -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, "不计分,换一题");