Files
Jyotisha/frontend/src/lib/rectification-chat-view.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

278 lines
11 KiB
TypeScript

/**
* What the rectification chat surface shows this render, derived from its
* state: the latest settled answer, the live choice card,
* the range delivery card, the read-only range, the question gap
* (preparing / unavailable / persisted question / collect-waiting /
* delivered / verified idle), where the persisted question hangs, and the
* timeline bar. Moved out of RectificationAgenticChat unchanged
* (TASK-rectification-code-split-20260926). Pure: the component applies the
* returned `nextOfferLock` itself; no React hooks here.
*/
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
} from "@/lib/rectification-candidate-result";
import { workingRectificationTime } from "@/lib/rectification-board-model";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationConversationState,
rectificationQuestionGapState,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
} from "@/lib/rectification-surface-state";
import {
buildRectificationTimeline,
type RectificationTimelineStage,
} from "@/lib/rectification-timeline-scale";
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import type { RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import {
interviewQuestionBlocksAdoptOffer,
nextSelectionCardLock,
questionIsAnswered,
resolveSelectionCardMessageKey,
turnQuestionFromCurrent,
type SelectionCardLock,
} from "@/lib/rectification-agentic/v9/turn-question";
import { placePersistedQuestion } from "@/lib/rectification-snapshot-messages";
import { isSettledAssistantMessage } from "@/lib/rectification-chat-messages";
import type { CandidateResult, CurrentQuestionModel } from "@/lib/rectification-chat-snapshot";
import type { RenderMessage } from "@/components/rectification-message-entry";
export type RectificationChatViewInput = Readonly<{
messages: RenderMessage[];
busy: boolean;
readonly: boolean;
shouldStartOpening: boolean;
openingRequested: boolean;
acceptingCandidateId: string | null;
selectionOfferLock: SelectionCardLock | null;
candidateResult: CandidateResult;
currentQuestion: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choiceCard: ChoiceCardModel | null;
caseStatus: RectificationCaseStatus | null;
caseSnapshotLoaded: boolean;
searchWindow: readonly [string, string] | null;
caseStage: RectificationTimelineStage | null;
nextUserActionId: string | null;
interviewStopReason: string | null;
interviewSessionOutcome: string | null;
questionRetryAttempts: number;
savedTime: string | null;
}>;
export function deriveRectificationChatView(input: RectificationChatViewInput) {
const {
messages,
busy,
readonly,
shouldStartOpening,
openingRequested,
acceptingCandidateId,
selectionOfferLock,
candidateResult,
currentQuestion,
questionSource,
choiceCard,
caseStatus,
caseSnapshotLoaded,
searchWindow,
caseStage,
nextUserActionId,
interviewStopReason,
interviewSessionOutcome,
questionRetryAttempts,
savedTime,
} = input;
const latestSettledAssistant = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
const latestLiveQuestion = [...messages].reverse().find((message) => (
message.role === "assistant"
&& message.question
&& !questionIsAnswered(message.question)
))?.question ?? null;
const showLiveChoiceCard = Boolean(
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& choiceCard
&& choiceCard.focus_id === latestLiveQuestion.focus_id
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
&& !busy
&& !readonly,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
// Cheap arithmetic over at most a dozen candidate minutes; recomputed with
// the render rather than memoised, matching the rest of this component.
const timelineView = buildRectificationTimeline({
searchWindow,
credibleRange: candidateResult?.credibleRange ?? null,
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const liveSelectionCardKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
// RectificationAgenticChat stores this lock during render (BUG-620); the
// comment on why sits next to that setSelectionOfferLock call.
const nextOfferLock = nextSelectionCardLock(selectionOfferLock, {
resultId: candidateResult?.resultId,
key: liveSelectionCardKey,
});
const selectionCardMessageKey = resolveSelectionCardMessageKey({
persistedOfferKey,
fallbackKey: latestSettledAssistant?.renderKey,
locked: nextOfferLock,
resultId: candidateResult?.resultId,
selectedTime: candidateResult?.selectedTime,
canOffer: canOfferCards,
});
// Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card
// for `busy` is what made the three columns vanish on 「更像这个」.
const keepSelectionCardsWhileBusy = Boolean(
acceptingCandidateId || candidateResult?.selectedTime,
);
const liveInterviewBlockingAdopt = Boolean(
candidateResult
&& !candidateResult.selectedTime
&& messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
)),
);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& (!busy || keepSelectionCardsWhileBusy)
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
&& latestSettledAssistant
&& !showSelectionCards,
);
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const deadChoice = interviewChoiceCardUnavailable({
questionKind: currentQuestion?.kind,
hasChoiceCard: Boolean(choiceCard),
questionId: currentQuestion?.question_id,
});
const liveQuestionOnMessages = Boolean(
latestSettledAssistant
&& latestSettledAssistant.question
&& currentQuestion
&& latestSettledAssistant.question.focus_id === currentQuestion.focus_id
&& !questionIsAnswered(latestSettledAssistant.question)
&& !deadChoice,
);
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server. Collect-waiting is the training-gate gap:
// no next card, keep the case open, do not show 「没有拿到下一个问题」.
const collectWaiting = interviewCollectWaiting({
stopReason: interviewStopReason,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
questionMissing: currentQuestion === null,
offeringCards: showSelectionCards,
});
// BUG-917: the same judgement the message entry makes, read off the latest
// settled assistant message: a tap question nobody can answer any more.
const unansweredDeadChoiceOnMessages = Boolean(
latestSettledAssistant?.question
&& (latestSettledAssistant.question.kind === "choice"
|| latestSettledAssistant.question.kind === "reverse_verify")
&& !latestSettledAssistant.question.answer_option
&& (latestSettledAssistant.question.status === "active"
|| latestSettledAssistant.question.status === "superseded")
&& !liveQuestionOnMessages,
);
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
unansweredDeadChoice: unansweredDeadChoiceOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null,
nextUserActionId,
collectWaiting: collectWaiting && !deadChoice,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
stopReason: interviewStopReason,
deadChoice,
questionId: currentQuestion?.question_id ?? null,
hasChoiceCard: Boolean(choiceCard),
busy,
readonly,
snapshotLoaded: caseSnapshotLoaded,
resumableCase,
retryAttempts: questionRetryAttempts,
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
});
// BUG-1046 (D3): the persisted question is drawn once. With an assistant
// message to carry it, it hangs there (BUG-678's intent) and other copies of
// the same focus come off; the standalone block is only the fallback when
// nothing can carry it. The gap state itself is unchanged.
const persistedTurnQuestion = questionGap === "persisted_question"
? turnQuestionFromCurrent(currentQuestion, choiceCard)
: null;
const placedQuestion = persistedTurnQuestion
? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage)
: null;
const placedByKey = placedQuestion && placedQuestion.messages !== messages
? new Map(placedQuestion.messages.map((message) => [message.renderKey, message]))
: null;
const showStandaloneQuestion = questionGap === "persisted_question"
&& (placedQuestion ? placedQuestion.standalone : true);
const verifiedIdleCopy = questionGap === "verified_idle"
? postAdoptVerifyDoneCopy(
savedTime,
messages.some((message) => message.question?.kind === "reverse_verify"),
)
: null;
const conversationState = rectificationConversationState({
messageCount: messages.length,
busy,
readonly,
shouldStartOpening,
openingStarted: openingRequested,
});
return {
latestSettledAssistant,
showLiveChoiceCard,
timelineView,
nextOfferLock,
selectionCardMessageKey,
liveInterviewBlockingAdopt,
showSelectionCards,
showReadonlyRange,
collectSpokenPrompt,
liveQuestionOnMessages,
questionGap,
placedByKey,
showStandaloneQuestion,
verifiedIdleCopy,
conversationState,
};
}
export type RectificationChatView = ReturnType<typeof deriveRectificationChatView>;