Files
Jyotisha/frontend/src/components/rectification-message-entry.tsx
T
Jesse_ChenandClaude Opus 5.5 9d302e515c feat(rectification): the server writes the evidence recap — 「记下了 N 件事」 with the ledger list collapsed under it (BUG-1136)
- Evidence turns whose batch accepted items: the body is the server recap
  built from those items (two or fewer listed inline, more as a count);
  the model no longer restates them (both prompt sets updated).
- The case snapshot carries each assistant turn's recorded evidence from
  the ledger (rejected/superseded excluded); the message shows it in a
  collapsed <details> list when more than two.
- Chinese date labels read straight into the event phrase (2016年入学);
  ISO labels keep their space.
- Assertions updated with original/new/reason notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 10:40:29 +08:00

285 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { memo, type MutableRefObject } from "react";
import {
ChatMessageActions,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { ChatMessageRow } from "@/components/chat-message-row";
import { RectificationChoiceCard } from "@/components/rectification-choice-card";
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
import type { ChatMessageView } from "@/lib/chat-message-view";
import {
noteSettledRowRender,
noteStreamingRowRender,
noteUnsplitListRender,
} from "@/lib/home-streaming-render-probe";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import {
CHOICE_MODE,
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_SKIP_QUESTION_MESSAGE,
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import {
questionIsAnswered,
questionIsDeadUnanswered,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { RECTIFICATION_STOPPED_NOTICE } from "@/lib/rectification-surface-state";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import { SIGN_LABELS, type SegmentSummary } from "@/lib/rectification-agentic/core/segment-summary";
export type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
stopped?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
segmentConsistency?: SegmentSummary;
/** BUG-1136: what this evidence turn recorded, from the ledger. */
recordedEvidence?: readonly string[];
};
export type RectificationMessageActions = Readonly<{
submitChoice: (key: ChoiceKey) => void;
submitStop: () => void;
copyMessage: (message: RenderMessage) => void;
onFeedback: (renderKey: string, requested: ChatMessageFeedback) => void;
}>;
export type RectificationMessageEntryProps = Readonly<{
message: RenderMessage;
busy: boolean;
readonly: boolean;
currentQuestionFocusId: string | null;
interactive: boolean;
liveChoiceCard: ChoiceCardModel | null;
choiceNonce: number;
savedTime: string | null;
copied: boolean;
feedback: ChatMessageFeedback | undefined;
actionsRef: MutableRefObject<RectificationMessageActions>;
}>;
export function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
): ChoiceCardModel | null {
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
return { ...live, prompt: question.prompt };
}
if (!question.options || question.options.length !== 4) return null;
return {
question_id: question.question_id,
method_id: "",
prompt: question.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: question.options.map((option) => ({
key: option.key,
label: option.label,
answer_class: "unsure",
role: "primary",
})),
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
function RectificationMessageEntryView({
message,
busy,
currentQuestionFocusId,
interactive,
liveChoiceCard,
choiceNonce,
savedTime,
copied,
feedback,
actionsRef,
}: RectificationMessageEntryProps) {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text);
// BUG-1137 (D3): the step rows are the waiting state while the reply is
// written; once it settles the rectification reply carries no
// 「已完成 N 步」 receipt — those steps are the system's own bookkeeping.
// Consultation replies keep theirs (ChatMessageRow is unchanged).
const settled = message.state === "settled";
const displayedMessage: RenderMessage = {
...message,
timeline: settled ? [] : rectificationTimelineRows({
trace: message.activityTrace,
receipt: message.completedReceipt,
activity: message.activity,
settled,
}),
};
const vargaSentence = !message.failed && !settled
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const question = message.question;
const liveQuestion = Boolean(
question
&& currentQuestionFocusId === question.focus_id
&& !questionIsAnswered(question)
&& interactive
&& (
question.kind === "collect_spoken"
|| (
liveChoiceCard
&& liveChoiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
// A superseded focus the reader never answered has no answer to show either:
// its options would render greyed and unclickable (BUG-917).
const unansweredDeadChoice = Boolean(
question
&& (!questionIsAnswered(question) || (question.status === "superseded" && !question.answer_option))
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? liveChoiceCard : null)
: null;
// BUG-1135 (D1, supersedes BUG-917's 「stem without options」): a choice
// nobody can answer any more draws nothing on its message — only the live
// focus prints a stem, so the same stem never shows twice.
const replacedQuestion = Boolean(
question
&& unansweredDeadChoice
&& (questionIsDeadUnanswered(question)
|| (currentQuestionFocusId !== null && currentQuestionFocusId !== question.focus_id)),
);
// BUG-1136 (D2): more than two recorded items read as 「记下了 N 件事」 in
// the body; the items themselves (ledger lines) sit here, collapsed.
const recorded = settled && (message.recordedEvidence?.length ?? 0) > 2
? message.recordedEvidence!
: null;
const recordedList = recorded ? (
<details className="rectification-recorded-evidence">
<summary>{`看记下的 ${recorded.length} 件`}</summary>
<ul>
{recorded.map((line, index) => <li key={`${index}:${line}`}>{line}</li>)}
</ul>
</details>
) : null;
const questionBlock = question && displayedMessage.state === "settled" && !replacedQuestion
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
{embeddedCard && (
<RectificationChoiceCard
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
variant="embedded"
card={embeddedCard}
pending={busy && liveQuestion}
disabled={!liveQuestion}
selectedKey={question.answer_option ?? ""}
onSelect={(key) => actionsRef.current.submitChoice(key)}
onStop={() => actionsRef.current.submitStop()}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
</p>
)}
</div>
)
: null;
const afterAnswer = recordedList || questionBlock
? <>{recordedList}{questionBlock}</>
: undefined;
return (
<>
{(!message.failed || Boolean(displayedMessage.text)) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/>
)}
{message.segmentConsistency?.no_rectification_needed && (
<section className="rectification-candidates rectification-range-delivery" aria-label="盘型一致,不用校正">
<strong>盘型一致,不用校正</strong>
<ul className="rectification-range-delivery__shared">
{message.segmentConsistency.charts.map((chart) => (
<li key={chart.chart}>{chart.chart} 上升:{chart.sign === null ? "未核验" : SIGN_LABELS[chart.sign]}(全窗一致)</li>
))}
</ul>
<p className="rectification-range-delivery__boundary">完整出生窗口内目标盘型一致;不等于确认了某一分钟。</p>
</section>
)}
{/* BUG-1056: copy and feedback only; the rectification reply has no rewrite action. */}
{showActions && (
<ChatMessageActions
feedback={feedback}
copied={copied}
onFeedback={(requested) => actionsRef.current.onFeedback(message.renderKey, requested)}
onCopy={() => void actionsRef.current.copyMessage(message)}
/>
)}
</>
);
}
export const RectificationMessageEntry = memo(function RectificationMessageEntry(
props: RectificationMessageEntryProps,
) {
if (props.message.state !== "settled") noteStreamingRowRender();
else noteSettledRowRender();
return <RectificationMessageEntryView {...props} />;
});
export function UnsplitRectificationMessageList({
messages,
copiedMessageKey,
feedbackByKey,
...shared
}: Omit<RectificationMessageEntryProps, "message" | "copied" | "feedback"> & {
messages: readonly RenderMessage[];
copiedMessageKey: string | null;
feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>>;
}) {
noteUnsplitListRender();
return (
<>
{messages.map((message) => {
noteSettledRowRender();
if (message.state !== "settled") noteStreamingRowRender();
return (
<RectificationMessageEntryView
key={message.renderKey}
{...shared}
message={message}
copied={copiedMessageKey === message.renderKey}
feedback={feedbackByKey[message.renderKey]}
/>
);
})}
</>
);
}