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
278 lines
11 KiB
TypeScript
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>;
|