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
184 lines
6.4 KiB
TypeScript
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}`;
|
|
}
|