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:
@@ -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:31–05:07 收到 04:31–04: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, "不计分,换一题");
|
||||
|
||||
Reference in New Issue
Block a user