Files
Jyotisha/frontend/src/lib/rectification-timeline-adapter.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 22:04:56 +08:00

153 lines
5.9 KiB
TypeScript

import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import type { AgentActivityView } from "./chat-message-view.ts";
import type { ConsultationTimelineKind, ConsultationTimelineRow } from "./consultation-run-timeline.ts";
import {
RECTIFICATION_ANALYZING_LIVE_LABEL,
RECTIFICATION_TOOL_PROGRESS_LABELS,
RECTIFICATION_TURN_PROGRESS_LABELS,
} from "./rectification-activity-labels.ts";
import type { PublicRectificationTool } from "./rectification-agentic/v9/public-receipt.ts";
import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "./rectification-varga-sentence.ts";
export const RECTIFICATION_TIMELINE_SOURCE_LIMIT = 8;
export const RECTIFICATION_TIMELINE_LIVE_ID = "activity-live";
const FAILED_SUFFIX = "未完成";
export type RectificationTimelineInput = Readonly<{
trace: readonly AgentActivityTraceItem[] | undefined;
receipt: CompletedActivityReceiptView | undefined;
activity: AgentActivityView | undefined;
settled: boolean;
}>;
function methodChips(receipt: CompletedActivityReceiptView | undefined): string[] {
if (!receipt) return [];
const seen = new Set<string>();
const chips: string[] = [];
for (const method of receipt.methods) {
const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method];
if (!label || seen.has(label)) continue;
seen.add(label);
chips.push(label);
if (chips.length >= RECTIFICATION_TIMELINE_SOURCE_LIMIT) break;
}
return chips;
}
const TURN_PROGRESS_LINES = new Set<string>(Object.values(RECTIFICATION_TURN_PROGRESS_LABELS));
/**
* Progress labels are the only ones that name an action still under way: the
* 「正在…」 labels and the typed-answer stage lines (「收到,正在对照你的档案…」,
* BUG-1047).
*/
function isProgressLabel(label: string): boolean {
const trimmed = label.trim();
return /^正在/.test(trimmed) || TURN_PROGRESS_LINES.has(trimmed);
}
/**
* The stage line of a typed answer names what the turn is doing, so it is also
* what the tool step currently under way shows (BUG-1047 D3). Finished steps
* keep their own labels.
*/
export function withLiveStepLabel(
trace: readonly AgentActivityTraceItem[],
label: string,
): readonly AgentActivityTraceItem[] {
if (!trace.some((item) => item.kind === "activity" && item.status === "live" && item.label !== label)) return trace;
return trace.map((item) => (
item.kind === "activity" && item.status === "live" ? { ...item, label } : item
));
}
function liveRowKind(phase: AgentActivityView["phase"] | undefined, label: string): ConsultationTimelineKind {
if (phase === "answer-composition") return "write";
if (label === RECTIFICATION_ANALYZING_LIVE_LABEL) return "think";
return "calculate";
}
/**
* Project the rectification agent's tool trace and receipt onto the same
* timeline rows the consultation surface renders, so both sessions share one
* step list, one live marker and one settled summary. Thinking text never
* reaches this surface: the public stream drops it on the server.
*/
/**
* A failed step is named by what it was doing (「重新对照盘面未完成」), not by its
* past-tense done label (「重新对照了盘面」), which would contradict the suffix.
*/
function failedStepName(item: AgentActivityTraceItem): string {
const doing = item.tool ? RECTIFICATION_TOOL_PROGRESS_LABELS[item.tool as PublicRectificationTool] : undefined;
return doing ? doing.replace(/^正在/, "").replace(/…$/, "") : item.label;
}
/**
* A finished step is named once (BUG-1050): the agent may call the same tool
* twice in one turn (set-focus writes the question, then rewrites it), and
* several tools share one plain label. The first finished row with a label
* stays; later finished rows with the same label are dropped, so 「已完成 N 步」
* counts the steps the reader actually sees. Live and failed rows are kept.
*/
function withoutRepeatedDoneSteps(rows: readonly ConsultationTimelineRow[]): ConsultationTimelineRow[] {
const seen = new Set<string>();
return rows.filter((row) => {
if (row.kind !== "calculate" || row.status !== "done") return true;
if (seen.has(row.label)) return false;
seen.add(row.label);
return true;
});
}
export function rectificationTimelineRows(input: RectificationTimelineInput): ConsultationTimelineRow[] {
const failedTool = input.receipt?.failedTool;
const rows: ConsultationTimelineRow[] = withoutRepeatedDoneSteps((input.trace ?? []).map((item) => {
if (item.kind === "think") {
return {
id: item.id,
kind: "think",
status: item.status,
label: item.label,
...(item.text?.trim() ? { thinkingText: item.text } : {}),
};
}
const failed = item.status === "done" && failedTool !== undefined && item.tool === failedTool;
return {
id: item.id,
kind: "calculate",
status: item.status,
label: failed ? `${failedStepName(item)}${FAILED_SUFFIX}` : item.label,
};
}));
const chips = methodChips(input.receipt);
if (chips.length > 0) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (!row || row.kind !== "calculate" || row.status !== "done" || row.label.endsWith(FAILED_SUFFIX)) continue;
rows[index] = { ...row, sources: chips };
break;
}
}
const hasLiveRow = rows.some((row) => row.status === "live");
const label = input.activity?.label ?? "";
if (!input.settled && !hasLiveRow && isProgressLabel(label)) {
rows.push({
id: RECTIFICATION_TIMELINE_LIVE_ID,
kind: liveRowKind(input.activity?.phase, label),
status: "live",
label,
});
} else if (!input.settled && !hasLiveRow && rows.length > 0) {
rows.push({
id: RECTIFICATION_TIMELINE_LIVE_ID,
kind: "think",
status: "live",
label: RECTIFICATION_ANALYZING_LIVE_LABEL,
});
}
return rows;
}