About 30 fixed rectification lines lose the receipt tone: no 「已记录。」 openings (收到 when the range holds, 好 when it moves), 麻烦 instead of 请, 时间 instead of 候选 in what the user reads, and no 「新建对话」 after adoption. Numbers, clocks, ranges and the honesty boundaries are unchanged. CHOICE_ACK_RE also recognises the new acknowledgements, so a model sentence that repeats one is still dropped; the delivery guard matches the delivery sentence by containment and does not depend on the opening. Full suite 4973 / fail 24, identical to 61d6f248; build keeps / Static. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/**
|
|
* A tapped choice (answer, stop, skip this probe) from the rectification chat:
|
|
* the local answered mark and live row, the structured POST, the transcript
|
|
* merge after the Case re-read, the follow-up turn on the same row, and the
|
|
* roll-back when the tap did not land (BUG-1046). Moved out of
|
|
* RectificationAgenticChat's `submitStructuredChoice` unchanged
|
|
* (TASK-rectification-code-split-20260926). A parameter function: the
|
|
* component hands in its state, setters and refs; no React hooks here.
|
|
*/
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
import type { ChatMessage } from "@/lib/chat-message-view";
|
|
import { emptyActivityTrace } from "@/lib/agent-activity-trace";
|
|
import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS } from "@/lib/rectification-activity-labels";
|
|
import {
|
|
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
|
|
isAbortError,
|
|
} from "@/lib/rectification-surface-state";
|
|
import {
|
|
CHOICE_ACTION,
|
|
SKIP_PROBE_ACTION,
|
|
STOP_ACTION,
|
|
shouldContinueAfterStructuredChoice,
|
|
type ChoiceOptionId,
|
|
} from "@/lib/rectification-agentic/v9/choice-action";
|
|
import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
|
import {
|
|
appendUnseenAssistantTurns,
|
|
applySnapshotTurnsToMessages,
|
|
mergeTurnQuestions,
|
|
} from "@/lib/rectification-snapshot-messages";
|
|
import {
|
|
markQuestionAnswered,
|
|
messagesFromTurns,
|
|
withdrawAnswerMark,
|
|
type PersistedTurn,
|
|
} from "@/lib/rectification-chat-messages";
|
|
import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot";
|
|
import type {
|
|
RectificationRunOutcome,
|
|
RectificationSnapshotLoader,
|
|
RectificationTurnAction,
|
|
RectificationTurnContinuation,
|
|
} from "@/lib/rectification-chat-turn-run";
|
|
import type { RenderMessage } from "@/components/rectification-message-entry";
|
|
|
|
export type RectificationStructuredChoiceAction = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
|
|
export type RectificationStructuredChoiceOverride = Readonly<{
|
|
focusId?: string;
|
|
questionId?: string | null;
|
|
probeId?: string | null;
|
|
caseRevision?: number | null;
|
|
}>;
|
|
|
|
export type RectificationStructuredChoiceDeps = Readonly<{
|
|
caseId: string;
|
|
sessionId: string;
|
|
busy: boolean;
|
|
readonly: boolean;
|
|
choiceCard: ChoiceCardModel | null;
|
|
currentQuestion: CurrentQuestionModel | null;
|
|
keyCounter: MutableRefObject<number>;
|
|
runAbort: MutableRefObject<AbortController | null>;
|
|
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
|
|
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
|
|
actionIdForChoice: (focusId: string, optionId: ChoiceOptionId) => string;
|
|
setError: Dispatch<SetStateAction<string>>;
|
|
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
|
|
setChoiceNonce: Dispatch<SetStateAction<number>>;
|
|
setNextUserActionId: Dispatch<SetStateAction<string | null>>;
|
|
setPending: (value: boolean) => void;
|
|
beginLiveRun: (baseLabel: string) => void;
|
|
loadCaseSnapshot: RectificationSnapshotLoader;
|
|
send: (
|
|
action: RectificationTurnAction,
|
|
messageText: string,
|
|
continuation?: RectificationTurnContinuation,
|
|
) => Promise<void>;
|
|
onCompleted?: () => void;
|
|
onMessagesChange?: (messages: ChatMessage[]) => void;
|
|
onProfileIncomplete?: () => void;
|
|
}>;
|
|
|
|
export async function runRectificationStructuredChoice(
|
|
deps: RectificationStructuredChoiceDeps,
|
|
action: RectificationStructuredChoiceAction,
|
|
optionId: ChoiceKey | "stop" | "skip_probe",
|
|
override?: RectificationStructuredChoiceOverride,
|
|
): Promise<void> {
|
|
const {
|
|
caseId,
|
|
sessionId,
|
|
busy,
|
|
readonly,
|
|
choiceCard,
|
|
currentQuestion,
|
|
keyCounter,
|
|
runAbort,
|
|
lastRunOutcome,
|
|
conversationAnchor,
|
|
actionIdForChoice,
|
|
setError,
|
|
setMessages,
|
|
setChoiceNonce,
|
|
setNextUserActionId,
|
|
setPending,
|
|
beginLiveRun,
|
|
loadCaseSnapshot,
|
|
send,
|
|
onCompleted,
|
|
onMessagesChange,
|
|
onProfileIncomplete,
|
|
} = deps;
|
|
const focusId = override?.focusId ?? choiceCard?.focus_id ?? (
|
|
action === STOP_ACTION && currentQuestion?.kind === "collect_spoken"
|
|
? currentQuestion.focus_id
|
|
: null
|
|
);
|
|
if (busy || readonly) return;
|
|
if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return;
|
|
if (focusId && !isPersistedFocusId(focusId)) {
|
|
setError("当前选择题已失效,请等待下一问。");
|
|
return;
|
|
}
|
|
const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null;
|
|
const probeId = override?.probeId ?? choiceCard?.probe_id;
|
|
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
|
|
const actionId = actionIdForChoice(focusId ?? caseId, optionId);
|
|
setError("");
|
|
keyCounter.current += 1;
|
|
const turnKey = keyCounter.current;
|
|
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
|
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
|
|
beginLiveRun(recordingLabel);
|
|
setMessages((current) => [
|
|
...markQuestionAnswered(current, focusId ?? "", optionId),
|
|
{
|
|
role: "assistant",
|
|
text: "",
|
|
renderKey: assistantRenderKey,
|
|
state: "thinking",
|
|
activityTrace: emptyActivityTrace(),
|
|
activity: {
|
|
phase: "evidence-validation",
|
|
label: recordingLabel,
|
|
startedAt: Date.now(),
|
|
},
|
|
},
|
|
]);
|
|
conversationAnchor.pinLatestTurn();
|
|
setPending(true);
|
|
// BUG-1046 (D2): a tap that did not land takes back its local answered
|
|
// mark and placeholder; the card key changes (answer_option → nonce), so
|
|
// the card remounts clean and one tappable card is left.
|
|
const rollBackFailedChoice = () => {
|
|
setMessages((current) => withdrawAnswerMark(
|
|
current.filter((message) => message.renderKey !== assistantRenderKey),
|
|
focusId ?? "",
|
|
optionId,
|
|
));
|
|
setChoiceNonce((current) => current + 1);
|
|
};
|
|
// The server may have moved on (stale_question, a dropped response after
|
|
// it applied): re-read the Case so the card reflects what it holds.
|
|
const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => {
|
|
setMessages((current) => applySnapshotTurnsToMessages(
|
|
current,
|
|
turns,
|
|
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
|
));
|
|
});
|
|
const abortController = new AbortController();
|
|
runAbort.current = abortController;
|
|
try {
|
|
const response = await fetch("/api/rectification/agent", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
signal: abortController.signal,
|
|
body: JSON.stringify({
|
|
caseId,
|
|
sessionId,
|
|
requestId: actionId,
|
|
action,
|
|
actionId,
|
|
...(focusId ? { focusId } : {}),
|
|
...(questionId ? { questionId } : {}),
|
|
probeId: probeId ?? null,
|
|
optionId: optionId === "stop" ? undefined : optionId,
|
|
expectedRevision,
|
|
origin: "choice_click",
|
|
clientActionId: actionId,
|
|
}),
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
rollBackFailedChoice();
|
|
if (payload?.code === "profile_incomplete") {
|
|
onProfileIncomplete?.();
|
|
return;
|
|
}
|
|
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
|
await reloadAfterFailedChoice();
|
|
return;
|
|
}
|
|
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
|
|
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
|
|
? payload.next_user_action.id.trim()
|
|
: "";
|
|
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
|
|
onCompleted?.();
|
|
await loadCaseSnapshot((turns) => {
|
|
if (willContinue) {
|
|
// D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn
|
|
// the server wrote while applying the tap joins the transcript,
|
|
// above the row the follow-up run continues on.
|
|
setMessages((current) => mergeTurnQuestions(
|
|
appendUnseenAssistantTurns(
|
|
current,
|
|
messagesFromTurns(turns as readonly PersistedTurn[]),
|
|
{ beforeRenderKey: assistantRenderKey },
|
|
),
|
|
turns,
|
|
));
|
|
return;
|
|
}
|
|
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
|
? payload.narration.trim()
|
|
: "收到。";
|
|
setMessages((current) => {
|
|
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
|
|
const withHistory = appendUnseenAssistantTurns(
|
|
mergeTurnQuestions(withoutPlaceholder, turns),
|
|
messagesFromTurns(turns as readonly PersistedTurn[]),
|
|
);
|
|
if (withHistory.length > withoutPlaceholder.length) return withHistory;
|
|
return [
|
|
...withHistory,
|
|
{
|
|
role: "assistant" as const,
|
|
text: narration,
|
|
renderKey: assistantRenderKey,
|
|
state: "settled" as const,
|
|
activity: undefined,
|
|
},
|
|
];
|
|
});
|
|
});
|
|
if (willContinue) {
|
|
// The follow-up turn continues on the row already in place: no removed
|
|
// row, no effect hop, and `busy` never drops in between (so the next
|
|
// card cannot flash before the turn hides it).
|
|
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
|
} else {
|
|
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
|
? payload.narration.trim()
|
|
: "收到。";
|
|
onMessagesChange?.([
|
|
{ role: "assistant", text: narration },
|
|
]);
|
|
}
|
|
} catch (caught) {
|
|
if (isAbortError(caught)) {
|
|
lastRunOutcome.current = "stopped";
|
|
setMessages((current) => current.map((message) => (
|
|
message.renderKey !== assistantRenderKey ? message : {
|
|
...message,
|
|
state: "settled" as const,
|
|
failed: false,
|
|
stopped: true,
|
|
activity: undefined,
|
|
}
|
|
)));
|
|
return;
|
|
}
|
|
lastRunOutcome.current = "failed";
|
|
rollBackFailedChoice();
|
|
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
|
await reloadAfterFailedChoice();
|
|
} finally {
|
|
if (runAbort.current === abortController) runAbort.current = null;
|
|
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
|
|
setPending(false);
|
|
}
|
|
}
|