fix(rectification): post-adopt check gets its own message and keeps its buttons; segment bubble copy (BUG-1146, BUG-1147)
Independent Staging Quality Gate / validate (push) Successful in 13m59s
Independent Staging Quality Gate / publish (push) Successful in 3m49s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 16:59:32 +08:00
co-authored by Claude Opus 5.5
parent c0f059125b
commit 282455cc83
13 changed files with 387 additions and 6 deletions
@@ -163,15 +163,31 @@ export async function POST(request: Request, context: RouteContext) {
}
const result = row as Record<string, unknown>;
const civil = parseSavedCivilFields(result);
let adoptTurnId: string | null = null;
try {
const { persistNextInterviewIfIdle } = await import(
const { persistNextInterviewIfIdle, writeAdoptFollowupTurn } = await import(
"@/lib/rectification-agentic/v9/answer-choice"
);
await persistNextInterviewIfIdle({
const next = await persistNextInterviewIfIdle({
accounting,
userId: user.id,
caseId,
});
// BUG-1146: the post-adopt check gets its own adopt message (not the previous answer's).
if (result.idempotent !== true) {
const after = await loadV9CaseDossier(accounting, user.id, caseId);
const adoptTurn = await writeAdoptFollowupTurn({
accounting,
userId: user.id,
caseId,
requestId: parsed.data.requestId,
resultId: after.latestResult?.resultId ?? parsed.data.resultId,
hostNarration: next.hostNarration,
focus: after.conversationSummary.activeFocus,
savedTime: typeof result.saved_time === "string" ? result.saved_time : null,
});
adoptTurnId = adoptTurn?.turnId ?? null;
}
} catch {
// Accept already committed; the next successful turn-exit will replace the stale collect.
}
@@ -188,6 +204,7 @@ export async function POST(request: Request, context: RouteContext) {
result_id: result.result_id,
case_status: result.case_status,
idempotent: result.idempotent === true,
...(adoptTurnId ? { adopt_turn_id: adoptTurnId } : {}),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
@@ -185,6 +185,12 @@ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): stri
return null;
}
/** BUG-1146: the adopt message that carries the first post-adopt check (VOICE 采用后一行). */
export function postAdoptCheckLead(savedTime: string | null | undefined): string {
const clock = /^\d{2}:\d{2}/.exec(savedTime?.trim() ?? "")?.[0];
return clock ? `已采用 ${clock}。再核对一件过去的事。` : "再核对一件过去的事。";
}
export function rangeDeliveryCollectClosed(candidateCount?: number): string {
const subject = rangeDeliveryCollectClosedSubject(candidateCount);
return subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。";
@@ -582,7 +588,8 @@ export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
const sentence3 = deliveryRangeUnnarrowed(input.openingRange, input.credibleRange)
? RECTIFICATION_USER_COPY.rangeDeliveryNotNarrowed
: "";
return `${sentence1}${sentence2}${sentence3}${REPRESENTATIVE_MINUTE_DISCLAIMER}`;
// BUG-1147: the segment delivery card prints the same boundary line; say it once.
return `${sentence1}${sentence2}${sentence3}${input.segmentSummary ? "" : REPRESENTATIVE_MINUTE_DISCLAIMER}`;
}
function deliveryRangeUnnarrowed(
@@ -26,6 +26,7 @@ import {
postAdoptVerifyDoneCopy,
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
postAdoptCheckLead,
} from "../user-copy.ts";
import {
isTargetedCollectFollowup,
@@ -343,6 +344,9 @@ function adoptHostNarration(input: {
productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain),
openingRange: openingRangeFromDossier(input.dossier),
});
// BUG-1147: the segment card already says which chart is credible and which cannot be told
// apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it.
if (input.dossier.case.rectificationDomain !== null && inference?.segment_summary) return delivered;
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const hint = rangeNarrowHint(
catalog.remainingLayers,
@@ -932,6 +936,8 @@ export async function applyRectificationChoice(
credibleBefore: previous.credible_range,
credibleAfter: applied.state.credible_range,
excludedRanges: excludedClusterRanges(previous.candidates, applied.state.candidates),
segmentProduct: dossier.case.rectificationDomain !== null
&& Boolean(applied.state.segment_summary ?? previous.segment_summary),
});
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
@@ -1615,6 +1621,47 @@ export async function persistCollectDenialTurn(input: {
return { streamText, turnId: turn.turnId };
}
/**
* BUG-1146: after 采用 the first post-adopt check is persisted by persistNextInterviewIfIdle, but the
* accept route wrote no turn. The read_only agent call that follows short-circuits as
* already_delivered within a minute of delivery, so the check had no message of its own and the
* client hung its stem under the previous answer's 「已记录,…」 narration. Write the adopt turn
* here, hang the check on it, and mark the delivery so the follow-up read_only call stays quiet.
*/
export async function writeAdoptFollowupTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
resultId: string | null;
hostNarration: string | null;
focus: ConversationFocus | null;
savedTime?: string | null;
}): Promise<{ turnId: string } | null> {
const narration = input.hostNarration?.trim();
if (!narration) return null;
const focus = input.focus;
const check = Boolean(focus && !focus.askedTurnId
&& (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"));
// The stem leaves the body once the card attaches; the lead is what the message says.
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: `${input.requestId}:adopt`,
userMessage: null,
assistantMessage: check ? `${postAdoptCheckLead(input.savedTime)}${narration}` : narration,
});
if (focus && check) {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus,
askedTurnId: turn.turnId,
});
}
if (input.resultId) markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
return { turnId: turn.turnId };
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
@@ -107,6 +107,8 @@ export function composeChoiceNarration(input: {
credibleAfter?: readonly [string, string] | null;
/** Clusters that left the delivered range with this answer (BUG-1086). */
excludedRanges?: readonly (readonly [string, string])[];
/** Segment (盘型) product: the minute leaderboard is not this product's language (BUG-1147). */
segmentProduct?: boolean;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
@@ -129,7 +131,7 @@ export function composeChoiceNarration(input: {
// (or the range split into segments). That is a change, never 「范围没变」.
const excluded = explainExcludedRanges(input.excludedRanges ?? []);
if (excluded) return `已记录,${excluded}。`;
const movement = explainScoreMovement(input.deltasByCluster ?? []);
const movement = input.segmentProduct ? "" : explainScoreMovement(input.deltasByCluster ?? []);
if (movement) return `已记录,范围没变;${movement}。`;
return "已记录,范围没变。";
}
@@ -2588,9 +2588,18 @@ export function buildMethodFollowupPlan(input: {
? schema.probe_year
: undefined;
const keepProbeYear = matchingVerify?.year || schemaYear;
// BUG-1146: the persisted focus carries the probe identity; the rebuilt followup must
// too, or its frame id ("reverse_verify:<theme>:score") never equals the stored
// question id ("probe:<key>") and the GET card comes back null after 采用.
const keepSemanticKey = persistedFocusProbeKey(focus);
const keepSplit = typeof schema?.candidate_split_hash === "string" && schema.candidate_split_hash.trim()
? schema.candidate_split_hash.trim()
: "";
return {
method_id: keepMethodId,
intent: focus.intent || "reverse_verify",
...(keepSemanticKey ? { semantic_key: keepSemanticKey } : {}),
...(keepSplit ? { candidate_split_hash: keepSplit } : {}),
ask_theme: keepAskTheme,
domain: focus.targetDomain,
kind_hint: collectKindFromFocus(focus),
@@ -24,6 +24,8 @@ import type {
RectificationTurnContinuation,
} from "@/lib/rectification-chat-turn-run";
import type { RenderMessage } from "@/components/rectification-message-entry";
import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages";
import { applySnapshotTurnsToMessages } from "@/lib/rectification-snapshot-messages";
export type RectificationCandidateAcceptDeps = Readonly<{
caseId: string;
@@ -155,6 +157,21 @@ export async function runRectificationCandidateAccept(
setSavedStatus("accepted");
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
if (typeof payload.adopt_turn_id === "string" && payload.adopt_turn_id) {
// BUG-1146: the server wrote the adopt message with the first post-adopt check hung on it;
// show that turn in place of the live row. The read_only run would only be
// already_delivered and leave the check under the previous answer.
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current.filter((message) => message.renderKey !== assistantRenderKey),
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
conversationAnchor.pinLatestTurn();
return;
}
await loadCaseSnapshot();
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {