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
This commit is contained in:
co-authored by
Claude Fable 5
parent
301eae4b76
commit
848e39e61f
@@ -57,9 +57,16 @@ async function resolvePersistenceForUser() {
|
||||
userId: user.id,
|
||||
persistence,
|
||||
jobs: createSupabasePersonalReportJobService(supabase),
|
||||
listSections: async (ownerId: string, requestId: string) => {
|
||||
// sectionId and attemptCount are consumed by resolveReportRead to derive
|
||||
// reader-visible chapter state; neither is forwarded to the browser raw.
|
||||
listSections: async (ownerId: string, requestId: string) => {
|
||||
const rows = await sections.list(ownerId, requestId);
|
||||
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
|
||||
return rows.map((row) => ({
|
||||
sectionId: row.sectionId,
|
||||
status: row.status,
|
||||
attemptCount: row.attemptCount,
|
||||
lastErrorCode: row.lastErrorCode,
|
||||
}));
|
||||
},
|
||||
loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => {
|
||||
const appendixRead = await supabase
|
||||
|
||||
@@ -3775,6 +3775,65 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Report generation progress: one cell per chapter, never a percentage.
|
||||
See frontend/DESIGN.md · 报告生成等待态. */
|
||||
.personal-report-state p.personal-report-progress-headline {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-sm);
|
||||
font-weight: 400;
|
||||
letter-spacing: -.3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.report-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
width: min(20rem, 100%);
|
||||
}
|
||||
.report-progress-track {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.report-progress-track li {
|
||||
flex: 1 1 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.report-progress-track li[data-state="done"] { background: var(--color-ink); }
|
||||
.report-progress-track li[data-state="failed"] { background: var(--color-ink-tertiary); }
|
||||
.report-progress-track li[data-state="writing"] { background: var(--color-ink-secondary); }
|
||||
.report-progress-chapters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.report-progress-chapters li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
.report-progress-chapters li[data-state="done"] { color: var(--color-ink); }
|
||||
.report-progress-chapters li[data-state="writing"] { color: var(--color-ink); }
|
||||
.report-progress-chapter-name { text-align: left; }
|
||||
.report-progress-chapter-status {
|
||||
color: var(--color-ink-tertiary);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-progress-chapters li[data-state="waiting"] { color: var(--color-ink-tertiary); }
|
||||
|
||||
.personal-report-document {
|
||||
width: min(900px, 100%);
|
||||
margin: var(--space-8) auto 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ReportDocument,
|
||||
type ReportDocumentV2,
|
||||
} from "@/lib/personal-report-contract";
|
||||
import { reportThemeLabel } from "@/lib/personal-report-progress";
|
||||
|
||||
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
|
||||
multi_system_consensus: "多系统一致",
|
||||
@@ -61,15 +62,6 @@ const DEPTH_LABELS: Record<ReportDocumentV2["depth"], string> = {
|
||||
research: "研究",
|
||||
};
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
general: "综合",
|
||||
career: "事业",
|
||||
wealth: "财富",
|
||||
marriage: "婚恋",
|
||||
health: "健康",
|
||||
education: "教育",
|
||||
timing: "应期",
|
||||
};
|
||||
|
||||
const ACTION_PRIORITY_LABELS: Record<ReportDocumentV2["actionNotes"][number]["priority"], string> = {
|
||||
now: "现在",
|
||||
@@ -152,7 +144,7 @@ function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[])
|
||||
}
|
||||
|
||||
function themeLabel(theme: string): string {
|
||||
return THEME_LABELS[theme] ?? theme;
|
||||
return reportThemeLabel(theme);
|
||||
}
|
||||
|
||||
function chartEvidenceRefs(chart: ReportChart): readonly string[] {
|
||||
|
||||
@@ -19,10 +19,18 @@ import { InlineSpinner } from "@/components/inline-spinner";
|
||||
|
||||
import { ReportActions } from "./report-actions";
|
||||
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
|
||||
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
|
||||
import {
|
||||
PERSONAL_REPORT_GENERATING_COPY,
|
||||
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
|
||||
} from "@/lib/personal-report-longform-copy";
|
||||
import {
|
||||
describeReportProgress,
|
||||
REPORT_PROGRESS_STALL_MS,
|
||||
reportProgressSignature,
|
||||
type ReportSectionProgress,
|
||||
type ReportSectionProgressState,
|
||||
} from "@/lib/personal-report-progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
|
||||
@@ -30,7 +38,12 @@ export type ReportLoadState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "unauthorized" }
|
||||
| { phase: "not-found" }
|
||||
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
|
||||
| {
|
||||
phase: "generating";
|
||||
progressPercent?: number;
|
||||
progressPhase?: string;
|
||||
sections?: readonly ReportSectionProgress[];
|
||||
}
|
||||
| { phase: "timed-out" }
|
||||
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
|
||||
| { phase: "invalid"; message: string }
|
||||
@@ -52,6 +65,31 @@ export interface ReportEnvelopeView {
|
||||
completedAt: string | null;
|
||||
progressPercent?: number;
|
||||
progressPhase?: string;
|
||||
sections?: readonly ReportSectionProgress[];
|
||||
}
|
||||
|
||||
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
|
||||
"done",
|
||||
"failed",
|
||||
"writing",
|
||||
"waiting",
|
||||
];
|
||||
|
||||
/**
|
||||
* Chapter rows are only trusted in the shape the route promises; anything else
|
||||
* is dropped rather than rendered as a mystery row.
|
||||
*/
|
||||
function readSectionProgress(value: unknown): readonly ReportSectionProgress[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const parsed = value.flatMap((entry) => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const { id, state } = entry;
|
||||
if (typeof id !== "string" || id.length === 0) return [];
|
||||
if (typeof state !== "string") return [];
|
||||
const known = SECTION_PROGRESS_STATES.find((candidate) => candidate === state);
|
||||
return known ? [{ id, state: known }] : [];
|
||||
});
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,10 +134,12 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
case "generating": {
|
||||
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
|
||||
const progressPhase = typeof view.progressPhase === "string" ? view.progressPhase : undefined;
|
||||
const sections = readSectionProgress(view.sections);
|
||||
return {
|
||||
phase: "generating",
|
||||
...(progressPercent === undefined ? {} : { progressPercent }),
|
||||
...(progressPhase === undefined ? {} : { progressPhase }),
|
||||
...(sections === undefined ? {} : { sections }),
|
||||
};
|
||||
}
|
||||
case "failed": {
|
||||
@@ -152,6 +192,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
|
||||
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
|
||||
const [waitedMs, setWaitedMs] = useState(0);
|
||||
// When observable progress last changed. Only a real change to the phase,
|
||||
// percent or chapter states moves this, so a chapter being retried keeps
|
||||
// the old mark and eventually trips the slow notice.
|
||||
const [progressMark, setProgressMark] = useState<{ signature: string; at: number } | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -170,9 +214,18 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
const next = classifyReportEnvelope(response.status, json);
|
||||
if (next.phase === "generating") {
|
||||
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
|
||||
const signature = reportProgressSignature({
|
||||
phase: next.progressPhase,
|
||||
percent: next.progressPercent,
|
||||
sections: next.sections,
|
||||
});
|
||||
setProgressMark((mark) => (
|
||||
mark && mark.signature === signature ? mark : { signature, at: Date.now() }
|
||||
));
|
||||
} else {
|
||||
setWaitStartedAt(null);
|
||||
setWaitedMs(0);
|
||||
setProgressMark(null);
|
||||
}
|
||||
setState(next);
|
||||
})
|
||||
@@ -191,6 +244,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
const keepWaiting = useCallback(() => {
|
||||
setWaitStartedAt(Date.now());
|
||||
setWaitedMs(0);
|
||||
setProgressMark(null);
|
||||
setState({ phase: "generating" });
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -229,12 +283,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
});
|
||||
|
||||
if (state.phase === "loading" || state.phase === "generating") {
|
||||
// The stall clock rides the existing one-second tick: waitStartedAt +
|
||||
// waitedMs is "now" without a second timer or an effect.
|
||||
const stalled = state.phase === "generating"
|
||||
&& waitStartedAt !== null
|
||||
&& progressMark !== null
|
||||
&& (waitStartedAt + waitedMs) - progressMark.at >= REPORT_PROGRESS_STALL_MS;
|
||||
const progress = state.phase === "generating"
|
||||
? describeReportProgress({
|
||||
phase: state.progressPhase,
|
||||
sections: state.sections,
|
||||
stalled,
|
||||
})
|
||||
: null;
|
||||
const writing = progress?.stage === "writing";
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<InlineSpinner className="text-primary" size={32} />
|
||||
<p role="status">
|
||||
{generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}
|
||||
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
|
||||
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
|
||||
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
|
||||
</p>
|
||||
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
|
||||
{generating && (
|
||||
<>
|
||||
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Chapter progress for a report that is still being written.
|
||||
*
|
||||
* One cell per chapter — the bar is the chapter list in compact form, so the
|
||||
* two can never disagree. Deliberately not a percentage bar: the job's percent
|
||||
* jumps 0→30 and 90→100 in single steps, and only the middle band tracks real
|
||||
* work, so a linear percentage would read as motion the backend has not made.
|
||||
*
|
||||
* Nothing animates on a timer. A chapter under retry looks stalled here
|
||||
* because it is stalled.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import type { ReportProgressView } from "@/lib/personal-report-progress";
|
||||
|
||||
export function PersonalReportProgressPanel({ progress }: { progress: ReportProgressView }) {
|
||||
return (
|
||||
<div className="report-progress">
|
||||
<ol className="report-progress-track" aria-hidden="true">
|
||||
{progress.chapters.map((chapter) => (
|
||||
<li key={chapter.id} data-state={chapter.state} />
|
||||
))}
|
||||
</ol>
|
||||
<ul className="report-progress-chapters">
|
||||
{progress.chapters.map((chapter) => (
|
||||
<li key={chapter.id} data-state={chapter.state}>
|
||||
<span className="report-progress-chapter-name">{chapter.label}</span>
|
||||
<span className="report-progress-chapter-status">{chapter.statusText}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
} from "./personal-report-generation";
|
||||
import { REPORT_STABLE_CODES } from "./personal-report-codes";
|
||||
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
|
||||
import {
|
||||
deriveSectionProgressState,
|
||||
type ReportSectionProgress,
|
||||
} from "./personal-report-progress";
|
||||
import type { ReportBillingPort } from "./personal-report-billing";
|
||||
import { checkSameOrigin } from "./personal-report-entitlement";
|
||||
import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
|
||||
@@ -138,6 +142,7 @@ export function reportView(
|
||||
row: PersonalReportRecord,
|
||||
job?: PersonalReportJobRecord | null,
|
||||
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
|
||||
sections?: readonly ReportSectionProgress[] | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -150,6 +155,7 @@ export function reportView(
|
||||
createdAt: row.createdAt,
|
||||
completedAt: row.completedAt,
|
||||
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
|
||||
...(sections && sections.length > 0 ? { sections } : {}),
|
||||
...(failure?.summary ? { failureSummary: failure.summary } : {}),
|
||||
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
|
||||
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
|
||||
@@ -648,8 +654,15 @@ export type ReportReadCoreDeps = Readonly<{
|
||||
document: unknown,
|
||||
) => { ok: true; document: unknown } | { ok: false };
|
||||
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
|
||||
/**
|
||||
* Durable section rows. `sectionId` and `attemptCount` are read here to
|
||||
* derive reader-visible chapter state and never leave the server as-is;
|
||||
* see personal-report-progress.ts for the boundary.
|
||||
*/
|
||||
listSections?: (userId: string, requestId: string) => Promise<readonly Readonly<{
|
||||
sectionId?: string;
|
||||
status: string;
|
||||
attemptCount?: number;
|
||||
lastErrorCode: string | null;
|
||||
}>[]>;
|
||||
loadLongformMarkdown?: (input: Readonly<{
|
||||
@@ -688,14 +701,30 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
const appendix = deps.loadLongformAppendix
|
||||
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
|
||||
: null;
|
||||
// Section rows back two different readers: the failure summary, and the
|
||||
// chapter progress the waiting screen shows while the run is still going.
|
||||
// A ready report needs neither, so it keeps its single-query read path.
|
||||
const sectionRows = (row.status === "failed" || row.status === "generating") && deps.listSections
|
||||
? await deps.listSections(deps.userId, row.requestId)
|
||||
: [];
|
||||
const failure = row.status === "failed"
|
||||
? summarizePersonalReportFailure({
|
||||
themeCount: row.requestedThemes.length,
|
||||
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
|
||||
sections: sectionRows,
|
||||
failureCode: row.failureCode,
|
||||
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
|
||||
})
|
||||
: null;
|
||||
const sectionProgress: readonly ReportSectionProgress[] | null = row.status === "generating"
|
||||
? sectionRows.flatMap((section) => (
|
||||
typeof section.sectionId === "string" && section.sectionId.length > 0
|
||||
? [{
|
||||
id: section.sectionId,
|
||||
state: deriveSectionProgressState(section.status, section.attemptCount ?? 0),
|
||||
}]
|
||||
: []
|
||||
))
|
||||
: null;
|
||||
if (row.status === "ready") {
|
||||
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
|
||||
? appendix.markdown
|
||||
@@ -742,7 +771,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
body: { report: reportView(row, job), reportDocument: validated.document },
|
||||
};
|
||||
}
|
||||
return { status: 200, body: { report: reportView(row, job, failure) } };
|
||||
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
|
||||
}
|
||||
|
||||
export type ReportDeleteCoreDeps = Readonly<{
|
||||
|
||||
Reference in New Issue
Block a user