- 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
169 lines
6.4 KiB
TypeScript
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",
|
|
}];
|
|
});
|
|
}
|