Files
Jyotisha/frontend/src/lib/rectification-chat-messages.ts
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

169 lines
6.4 KiB
TypeScript

/**
* Pure transcript helpers of the rectification chat surface: persisted turns
* to render messages, the local "answered" mark on a question and its undo,
* and the persisted receipt view. Moved out of rectification-agentic-chat.tsx
* unchanged (TASK-rectification-code-split-20260926); no React hooks here.
*/
import { activityTraceFromReceipt } from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import { isStructuredChoiceUserText } from "@/lib/rectification-agentic/v9/choice-action";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
import { parseRecordedEvidence, parseTurnQuestion, questionIsAnswered } from "@/lib/rectification-agentic/v9/turn-question";
import type { RenderMessage } from "@/components/rectification-message-entry";
import { parseSegmentSummary } from "./rectification-agentic/core/segment-summary.ts";
export type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
segment_consistency?: unknown;
recorded_evidence?: unknown;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
}>;
export function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message;
return {
...message,
question: {
...question,
status: "resolved",
answer_option: selected === "typed" ? null : selected,
},
};
});
}
/**
* BUG-1046: undo `markQuestionAnswered` when the answer never reached the
* server, so the question is active again with no option picked. Only the mark
* this action made is undone; a server-resolved question is left alone.
*/
export function withdrawAnswerMark(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
const marked = selected === "typed" ? null : selected;
return current.map((message) => {
const question = message.question;
if (!question || question.focus_id !== focusId) return message;
if (question.status !== "resolved" || question.answer_option !== marked) return message;
return { ...message, question: { ...question, status: "active", answer_option: null } };
});
}
export function isSettledAssistantMessage(message: RenderMessage): boolean {
return message.role === "assistant" && message.state === "settled" && !message.failed;
}
export function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] {
return Array.isArray(payload?.turns) ? payload.turns : [];
}
export function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
let state = createRectificationActivityReceiptState();
for (const activity of receipt.tool_activities) {
if (!isPublicRectificationTool(activity.tool)
|| (activity.status !== "completed" && activity.status !== "failed")) continue;
state = reduceRectificationActivityReceipt(state, {
tool: activity.tool,
status: activity.status,
methods: activity.status === "completed" && Array.isArray(activity.methods)
? activity.methods.filter(isPublicRectificationMethod)
: [],
});
}
return receiptFromRectificationActivityState(state);
}
return {
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
};
}
export function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean {
return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool);
}
export function persistedTurnFailed(turn: PersistedTurn): boolean {
return turn.status === "failed"
|| turn.status === "retryable"
|| turn.receipt?.status === "failed"
|| turn.receipt?.status === "degraded"
|| turn.receipt?.status === "blocked";
}
export function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
const failed = persistedTurnFailed(turn);
const raw = failed ? "" : turn.text ?? "";
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: raw,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt,
activityTrace: activityTraceFromReceipt(completedReceipt),
failed,
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
segmentConsistency: parseSegmentSummary(turn.segment_consistency) ?? undefined,
recordedEvidence: parseRecordedEvidence(turn.recorded_evidence),
candidateOffer: typeof turn.offer_result_id === "string"
? { resultId: turn.offer_result_id }
: undefined,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
if (isStructuredChoiceUserText(turn.text)) return [];
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}