fix(rectification): post-adopt check gets its own message and keeps its buttons; segment bubble copy (BUG-1146, BUG-1147)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c0f059125b
commit
282455cc83
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user