Files
Jyotisha/frontend/src/lib/personal-report-progress.ts
T
Jesse_ChenandClaude Fable 5 848e39e61f feat(reports): show chapter progress while a report is being written (BUG-601)
The worker already persists a phase ladder and durable per-section rows;
the waiting screen parsed the progress fields and rendered none of them.
Chapter progress now drives the screen: one cell per chapter rather than
a percentage bar, since the job percent jumps 0->30 and 90->100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-09 03:30:15 +00:00

184 lines
6.4 KiB
TypeScript

/**
* Personal-report generation progress: the shared vocabulary between the API
* route (which reads durable section rows) and the waiting screen.
*
* The waiting screen exists because a full report takes minutes. Everything
* here is derived from state the worker already persists — nothing is
* interpolated, smoothed, or extrapolated. If the backend has not moved, the
* screen does not move either.
*
* Privacy boundary, drawn against BUG-043 (a rectification surface that
* re-rendered backend evidence state as a panel the user had to manage):
* chapter names and per-chapter state are the reader's own deliverable
* structure on a read-only waiting screen, so they may be shown. Attempt
* counts, raw error codes, lease tokens, job ids and payloads are backend
* state and must never cross into the response.
* `deriveSectionProgressState` is the one place attemptCount is read, and it
* runs on the server.
*/
/** Chapter titles shown to the reader; shared with the report document view. */
export const REPORT_THEME_LABELS: Readonly<Record<string, string>> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
export function reportThemeLabel(theme: string): string {
return REPORT_THEME_LABELS[theme] ?? theme;
}
/**
* Per-chapter state as the reader sees it.
* - `done` section row is `ready`
* - `failed` section row is `blocked` (attempts exhausted); still counts as
* finished work, so the bar advances rather than stalling
* - `writing` row is `pending` but has been claimed at least once
* - `waiting` row is `pending` and untouched
*/
export type ReportSectionProgressState = "done" | "failed" | "writing" | "waiting";
export type ReportSectionProgress = Readonly<{
id: string;
state: ReportSectionProgressState;
}>;
/**
* Server-side mapping from a durable section row to reader-visible state.
*
* `start_personal_report_section` bumps `attempt_count` and leaves `status`
* at `pending`, so a claimed-but-unfinished chapter is exactly
* `pending && attemptCount > 0`. That makes "which chapter is being written"
* an observed fact rather than an inference from the completed count — the
* section list arrives ordered by section_id, not by write order, so counting
* forward from the finished chapters would name the wrong one.
*/
export function deriveSectionProgressState(
status: string,
attemptCount: number,
): ReportSectionProgressState {
if (status === "ready") return "done";
if (status === "blocked") return "failed";
return attemptCount > 0 ? "writing" : "waiting";
}
/**
* Stage of the generation run, read from the job's progress phase.
* The worker ladder is queued(0) → loading_context(10) → generating_report(30)
* → section:<id>(30..85, one step per finished chapter) → persisting_report(90)
* → ready(100).
*/
export type ReportProgressStage = "preparing" | "writing" | "finishing";
export function classifyReportProgressStage(phase: string | undefined): ReportProgressStage {
if (phase === "persisting_report") return "finishing";
if (phase !== undefined && phase.startsWith("section:")) return "writing";
return "preparing";
}
/**
* How long one unchanged progress signature may last before the screen says
* so. A chapter that fails is retried (max_attempts 2) with no change to the
* job's phase or percent, so this is the only signal the reader gets that a
* chapter is taking two passes. We say it is slow; we do not claim to know
* which attempt it is, because attemptCount never reaches the client.
*/
export const REPORT_PROGRESS_STALL_MS = 90_000;
export type ReportProgressChapter = Readonly<{
id: string;
label: string;
state: ReportSectionProgressState;
statusText: string;
}>;
export type ReportProgressView = Readonly<{
stage: ReportProgressStage;
/** Sentence for the status line; always present. */
headline: string;
/** Empty until the run reaches the writing stage. */
chapters: readonly ReportProgressChapter[];
finished: number;
total: number;
/** True when at least one chapter is blocked; the run can still finish. */
hasFailure: boolean;
}>;
const STATE_TEXT: Readonly<Record<ReportSectionProgressState, string>> = {
done: "已完成",
failed: "写作失败",
writing: "正在写",
waiting: "待写",
};
/**
* Build the reader-facing view. `stalled` is passed in rather than computed
* from a clock so this stays a pure function the tests can drive.
*/
export function describeReportProgress(input: Readonly<{
phase?: string;
sections?: readonly ReportSectionProgress[];
stalled?: boolean;
}>): ReportProgressView {
const stage = classifyReportProgressStage(input.phase);
const sections = input.sections ?? [];
const chapters = sections.map((section) => ({
id: section.id,
label: reportThemeLabel(section.id),
state: section.state,
statusText: section.state === "writing" && input.stalled
? "用时较长,仍在写"
: STATE_TEXT[section.state],
}));
const finished = chapters.filter(
(chapter) => chapter.state === "done" || chapter.state === "failed",
).length;
const total = chapters.length;
const hasFailure = chapters.some((chapter) => chapter.state === "failed");
if (stage === "finishing") {
return { stage, headline: "正在整理成文", chapters, finished, total, hasFailure };
}
if (stage === "writing" && total > 0) {
return {
stage,
headline: `已完成 ${finished} / ${total} 章`,
chapters,
finished,
total,
hasFailure,
};
}
// Either the run has not reached the chapters yet, or the section rows are
// not readable this poll. Both are "preparing" as far as the reader is
// concerned; we never invent a chapter count.
return {
stage: "preparing",
headline: "正在准备你的星盘证据",
chapters: [],
finished: 0,
total: 0,
hasFailure: false,
};
}
/**
* A signature that changes exactly when observable progress changes. The
* waiting screen restarts its stall timer on a new signature, so a chapter
* being retried (phase and percent unchanged) is what trips the slow notice.
*/
export function reportProgressSignature(input: Readonly<{
phase?: string;
percent?: number;
sections?: readonly ReportSectionProgress[];
}>): string {
const sections = (input.sections ?? [])
.map((section) => `${section.id}:${section.state}`)
.join(",");
return `${input.phase ?? ""}|${input.percent ?? ""}|${sections}`;
}