fix(rectification): match the delivery inside the acknowledged reply (BUG-1241)
The answered choice persists the acknowledgement, a blank line, and the rewritten delivery. The exit fill-in required equality and wrote the deterministic delivery again. Check containment instead, and pin it on the real answer path. The suspected widen staleness regression did not hold; keep a guard test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6e16dc5e16
commit
e62d0a3257
@@ -71,7 +71,9 @@ export function deliveryCarrierNarration(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the latest assistant text is the stored delivery sentence.
|
||||
* True when the latest assistant text carries the stored delivery sentence.
|
||||
* The answered choice persists 「已记录,…」 + a blank line + that sentence,
|
||||
* so this is a containment check, not equality (BUG-1241 fix sheet).
|
||||
* Whitespace is ignored. A needle shorter than 8 characters does not match.
|
||||
*/
|
||||
export function deliveryCarrierMatchesLastAssistant(input: {
|
||||
@@ -91,7 +93,7 @@ export function deliveryCarrierMatchesLastAssistant(input: {
|
||||
if (turn?.role !== "assistant") continue;
|
||||
const text = typeof turn.text === "string" ? squash(turn.text) : "";
|
||||
if (!text) continue;
|
||||
return text === needle;
|
||||
return text.includes(needle);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { applyRectificationChoice, persistExhaustionGateTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
|
||||
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
CASE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
RESULT_ID,
|
||||
SESSION_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
@@ -734,6 +736,53 @@ test("last structured choice emits the adoption range and persists the same narr
|
||||
assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /排盘用/);
|
||||
});
|
||||
|
||||
// BUG-1241 fix sheet: drive the real answer path with the adopt-narration
|
||||
// rewrite, take the assistant text it actually persists, and make sure the
|
||||
// exit fill-in does not add the deterministic delivery underneath it.
|
||||
test("a rewritten delivery reply is not followed by a second exit fill-in turn", async () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
const rewrite = "因为再问下去也分不开这两个时间,这一轮到这里停住,下面是这次的结果。";
|
||||
const accounting = persistChoiceAccounting(adoptionDossier());
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: CHOICE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: RELOCATION_2015_PROBE.id,
|
||||
optionId: "A",
|
||||
expectedRevision: adoptionInference().revision,
|
||||
narrateAdopt: async () => rewrite,
|
||||
});
|
||||
assert.equal(applied.nextAction.can_adopt, true);
|
||||
const answerTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
const persisted = String(answerTurn?.args.p_assistant_message ?? "");
|
||||
assert.match(persisted, new RegExp(rewrite));
|
||||
|
||||
const deterministic = String(applied.narration).includes(rewrite) ? "" : String(applied.narration);
|
||||
const fillIn = deterministic || "目前范围 04:45–05:15。这只是代表性候选,不是已确认的唯一出生分钟。";
|
||||
const gate = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
turns: [
|
||||
{ id: "t-user", role: "user", text: "A", status: "completed", created_at: "2026-10-06T00:00:00.000Z" },
|
||||
{ id: "t-answer", role: "assistant", text: persisted, status: "completed", created_at: "2026-10-06T00:00:01.000Z" },
|
||||
],
|
||||
}),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false }),
|
||||
});
|
||||
await persistExhaustionGateTurn({
|
||||
accounting: gate.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
hostNarration: fillIn,
|
||||
resultId: RESULT_ID,
|
||||
});
|
||||
assert.equal(gate.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 0);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
|
||||
test("clicking A applies the choice without invoking a language model", async () => {
|
||||
const accounting = choiceAccounting();
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
|
||||
@@ -14,7 +14,10 @@ import { RectificationRangeDelivery } from "../src/components/rectification-rang
|
||||
import { RectificationSegmentDelivery } from "../src/components/rectification-segment-delivery.tsx";
|
||||
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
|
||||
import type { RangeDeliveryProjection } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
|
||||
import { persistExhaustionGateTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { applyRectificationChoice, persistExhaustionGateTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { WIDEN_WINDOW_INTENT, WIDEN_WINDOW_KIND } from "../src/lib/rectification-agentic/v9/block-scan.ts";
|
||||
import { buildWidenWindowFrame } from "../src/lib/rectification-agentic/v9/window-widen.ts";
|
||||
import { dossierResponse } from "../src/lib/rectification-agentic/v9/case-dossier-response.ts";
|
||||
import { decisionInputsFromSnapshot } from "../src/lib/rectification-agentic/v9/decision-inputs.ts";
|
||||
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
@@ -32,9 +35,12 @@ import {
|
||||
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { adoptionRefusalCopy, runRectificationCandidateAccept } from "../src/lib/rectification-chat-accept-run.ts";
|
||||
import {
|
||||
activeFocusFixture,
|
||||
CASE_DOSSIER_RPC,
|
||||
CASE_ID,
|
||||
candidateSnapshotFixture,
|
||||
FOCUS_ID,
|
||||
SESSION_ID,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
RESULT_ID,
|
||||
@@ -135,7 +141,12 @@ function inference(withProbe: boolean, tied: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
function parsed(withProbe: boolean, tied: boolean, fingerprint: string | null): V9CaseDossier {
|
||||
function rawDossier(
|
||||
withProbe: boolean,
|
||||
tied: boolean,
|
||||
fingerprint: string | null,
|
||||
extra: { candidateRange?: { start_time: string; end_time: string }; activeFocus?: unknown } = {},
|
||||
) {
|
||||
const supportA = tied ? 16 : 40;
|
||||
const supportB = tied ? 16 : 8;
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
@@ -162,10 +173,28 @@ function parsed(withProbe: boolean, tied: boolean, fingerprint: string | null):
|
||||
const rpc = dossierFixture({
|
||||
evidenceCount: evidence.length,
|
||||
evidence,
|
||||
candidateRange: { start_time: OPEN_START, end_time: OPEN_END },
|
||||
candidateRange: extra.candidateRange ?? { start_time: OPEN_START, end_time: OPEN_END },
|
||||
latestResult: snapshot,
|
||||
...(extra.activeFocus ? {
|
||||
conversationSummary: {
|
||||
confirmed_evidence_summary: [],
|
||||
pending_revisions: [],
|
||||
active_focus: extra.activeFocus,
|
||||
declined_skipped_topics: [],
|
||||
candidate_divergence_summary: null,
|
||||
missing_evidence_categories: [],
|
||||
last_result_policy: null,
|
||||
summary_version: 1,
|
||||
updated_at: "2026-10-06T10:00:00.000Z",
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
(rpc.case as { rectification_domain?: string }).rectification_domain = "general";
|
||||
return rpc;
|
||||
}
|
||||
|
||||
function parsed(withProbe: boolean, tied: boolean, fingerprint: string | null): V9CaseDossier {
|
||||
const rpc = rawDossier(withProbe, tied, fingerprint);
|
||||
const dossier = parseV9CaseDossier(rpc);
|
||||
if (!dossier) throw new Error("dossier did not parse");
|
||||
return dossier;
|
||||
@@ -547,3 +576,161 @@ test("a refused adoption refreshes the snapshot and says so in Chinese", async (
|
||||
assert.equal(adoptionRefusalCopy({ error: "adoption_not_allowed" }), "这次的结果还不能采用,已刷新到最新状态");
|
||||
assert.doesNotMatch(adoptionRefusalCopy({ code: "adoption_not_allowed" }), /adoption_not_allowed/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix sheet 2026-10-06 (F1, BUG-1241 reopened): the answered choice persists
|
||||
// 「已记录,…」 + blank line + the rewrite, never the rewrite alone.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const ack of ["已记录,范围没变。", "已记录,范围没变;04:53 仍领先,05:00 落后。"]) {
|
||||
test(`the exit fill-in does not write again when the reply is 「${ack}」 + the stored rewrite`, async () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: REWRITE });
|
||||
const accounting = gateAccounting(`${ack}\n\n${REWRITE}`);
|
||||
await persistExhaustionGateTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
hostNarration: FALLBACK,
|
||||
resultId: RESULT_ID,
|
||||
});
|
||||
assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 0);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
}
|
||||
|
||||
test("the exit fill-in still writes once when the reply is only the acknowledgement", async () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: REWRITE });
|
||||
const accounting = gateAccounting("已记录,范围没变。");
|
||||
await persistExhaustionGateTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
hostNarration: FALLBACK,
|
||||
resultId: RESULT_ID,
|
||||
});
|
||||
assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 1);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix sheet 2026-10-06 (F2): guard only. Widening must never deliver or adopt
|
||||
// on the old window's tied candidates, even when the stored snapshot's
|
||||
// evidence fingerprint still matches and the rescore after widening fails.
|
||||
// (The suspected BUG-1246 did not hold: widen/block choices decide with no
|
||||
// inference state, so they always collect; this pins that.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NARROW = { start_time: RANGE_START, end_time: RANGE_END };
|
||||
const WIDE = { start_time: "04:15", end_time: "05:45" };
|
||||
|
||||
function widenFocus() {
|
||||
const frame = buildWidenWindowFrame({ reportedTime: TIME_B, currentWindow: NARROW });
|
||||
assert.ok(frame);
|
||||
return activeFocusFixture({
|
||||
intent: WIDEN_WINDOW_INTENT,
|
||||
targetDomain: null,
|
||||
targetKind: null,
|
||||
questionId: "widen_window:search_range:holdout",
|
||||
expectedAnswerSchema: {
|
||||
choice: {
|
||||
prompt: frame.prompt,
|
||||
option_a: frame.option_a_hint,
|
||||
option_b: frame.option_b_hint,
|
||||
option_c: frame.neither_label,
|
||||
option_d: frame.unsure_label,
|
||||
options: [
|
||||
{ key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" },
|
||||
{ key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" },
|
||||
{ key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" },
|
||||
{ key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" },
|
||||
],
|
||||
},
|
||||
choice_kind: WIDEN_WINDOW_KIND,
|
||||
scoring: false,
|
||||
widen_windows: {
|
||||
A: { radius: 30, ...WIDE },
|
||||
B: { radius: 60, start_time: "04:00", end_time: "06:00" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("answering widen A with a failed rescore never delivers or adopts on the old window's candidates", async () => {
|
||||
const draft = parsed(false, true, null);
|
||||
const fingerprint = evidenceLedgerFingerprint(draft.evidence);
|
||||
// Control: the same stored snapshot, read as current, is a tied delivery that may be adopted.
|
||||
const control = publicDecisionFields(decideFromDossier(
|
||||
parsed(false, true, fingerprint),
|
||||
decisionInputsFromSnapshot(BIRTH, { currentEvidenceFingerprint: fingerprint }),
|
||||
));
|
||||
assert.equal(control.can_adopt, true);
|
||||
|
||||
const current = { value: rawDossier(false, true, fingerprint, { candidateRange: NARROW, activeFocus: widenFocus() }) };
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => current.value,
|
||||
get_agentic_rectification_case_compute: () => ({
|
||||
case_id: CASE_ID,
|
||||
skill_version: "9.0.0",
|
||||
baseline_profile_fingerprint: "a".repeat(64),
|
||||
baseline_birth_snapshot: {
|
||||
birth_date: BIRTH,
|
||||
latitude: 31.23,
|
||||
longitude: 121.47,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "approximate",
|
||||
reported_birth_time: TIME_B,
|
||||
},
|
||||
candidate_range: (current.value.case as { candidate_range: unknown }).candidate_range,
|
||||
}),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false }),
|
||||
apply_agentic_rectification_choice_action: (_fn: string, args: Record<string, unknown>) => ({
|
||||
action_id: args.p_action_id,
|
||||
status: "applied",
|
||||
idempotent: false,
|
||||
question_id: args.p_question_id,
|
||||
option_id: args.p_option_id,
|
||||
revision: 1,
|
||||
narration: args.p_narration,
|
||||
focus_status: args.p_focus_status,
|
||||
}),
|
||||
widen_agentic_rectification_dated_window: () => {
|
||||
current.value = rawDossier(false, true, fingerprint, { candidateRange: WIDE });
|
||||
return { case_id: CASE_ID, stage: "minute", candidate_range: WIDE };
|
||||
},
|
||||
});
|
||||
const savedEnv = {
|
||||
algorithm: process.env.RECTIFICATION_ALGORITHM_VERSION,
|
||||
policy: process.env.RECTIFICATION_DECISION_POLICY_VERSION,
|
||||
};
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = ALGO;
|
||||
process.env.RECTIFICATION_DECISION_POLICY_VERSION = POLICY;
|
||||
let receipt: Awaited<ReturnType<typeof applyRectificationChoice>>;
|
||||
try {
|
||||
receipt = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaab46",
|
||||
action: CHOICE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
optionId: "A",
|
||||
expectedRevision: 0,
|
||||
});
|
||||
} finally {
|
||||
for (const [key, value] of [
|
||||
["RECTIFICATION_ALGORITHM_VERSION", savedEnv.algorithm],
|
||||
["RECTIFICATION_DECISION_POLICY_VERSION", savedEnv.policy],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
assert.ok(accounting.calls.some((call) => call.fn === "widen_agentic_rectification_dated_window"));
|
||||
assert.equal(receipt.snapshotCurrent, false);
|
||||
assert.equal(receipt.nextAction.can_adopt, false);
|
||||
assert.notEqual(receipt.nextAction.type, "ready_to_adopt");
|
||||
assert.notEqual(receipt.nextAction.type, "complete_with_range");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user