/** * 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> = { 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:(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> = { 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}`; }