feat(report): render longform Markdown as the report and close gaps2 holes

New reports skip the writer, persist pl9 Markdown as the body, and settle zero-token usage on the catalog model. Planned longform sections now emit blocked rows instead of vanishing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 21:46:16 +08:00
parent e428e9d056
commit cfcd369d4f
36 changed files with 2290 additions and 402 deletions
@@ -2,106 +2,26 @@ import { NextResponse } from "next/server";
import {
LONGFORM_APPENDIX_TABLE,
nextLongformAppendixState,
parseLongformAppendixRow,
} from "@/lib/personal-report-longform-appendix";
import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "@/lib/personal-report-longform-copy";
import {
checkSameOrigin,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "@/lib/server-owned-birth-profile";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 300;
export const maxDuration = 60;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const APPENDIX_SELECT =
"report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code";
type RouteContext = { params: Promise<{ reportId: string }> };
type ProfessionalReferenceResponse = Readonly<{
format?: unknown;
markdown?: unknown;
}>;
function utcToday(): string {
return new Date().toISOString().slice(0, 10);
}
function birthPayload(
row: unknown,
extras: Readonly<{
today: string;
targetYear: number;
candidateRange: { start_time: string; end_time: string } | null;
birthTimeAccuracy: "confirmed" | "provisional";
}>,
): Record<string, unknown> | null {
const profile = globalBirthProfileFromAccountRow(row);
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? "");
if (
!dateMatch
|| !timeMatch
|| profile.latitude === null
|| profile.longitude === null
|| profile.timezoneOffset === null
) {
return null;
}
const year = Number.parseInt(dateMatch[1], 10);
return {
year,
month: Number.parseInt(dateMatch[2], 10),
day: Number.parseInt(dateMatch[3], 10),
hour: Number.parseInt(timeMatch[1], 10),
minute: Number.parseInt(timeMatch[2], 10),
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
ayanamsa: profile.ayanamsa,
format: "markdown",
packs: ["full"],
today: extras.today,
target_year: extras.targetYear,
age: extras.targetYear - year,
birth_time_accuracy: extras.birthTimeAccuracy,
...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}),
};
}
function unavailableResponse() {
return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 503 });
}
function upstreamError(status: number, retryAfter: string | null) {
return NextResponse.json(
{ error: status === 429 ? "全量数据附录生成繁忙,请稍后重试" : "全量数据附录暂不可用" },
{
status: status === 429 ? 429 : 503,
headers: retryAfter ? { "Retry-After": retryAfter } : undefined,
},
);
}
function tryAdminClient() {
try {
return createAdminSupabaseClient();
} catch {
return null;
}
}
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
@@ -142,79 +62,15 @@ export async function POST(request: Request, context: RouteContext) {
if (cached?.status === "ready" && cached.markdown) {
return NextResponse.json({ format: "markdown", markdown: cached.markdown });
}
if (cached?.status === "unavailable") {
return unavailableResponse();
}
const { data: profileRow, error: profileError } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
const admin = tryAdminClient();
const range = admin
? await loadReportCandidateRange(admin, { userId: user.id })
: null;
const today = utcToday();
const payload = profileError ? null : birthPayload(profileRow, {
today,
targetYear: Number(today.slice(0, 4)),
candidateRange: range ? { start_time: range.startTime, end_time: range.endTime } : null,
birthTimeAccuracy: range && range.startTime !== range.endTime ? "provisional" : "confirmed",
});
if (!payload) {
return NextResponse.json({ error: "出生资料不完整" }, { status: 422 });
}
const persistAppendix = async (input: {
successMarkdown?: string | null;
errorCode?: string | null;
}) => {
if (!admin || !report.requestId) return;
const next = nextLongformAppendixState({
current: cached,
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
await admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: reportId,
user_id: user.id,
request_id: report.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
});
};
const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
});
if (!upstream.ok) {
await persistAppendix({
errorCode: upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
}).catch(() => undefined);
return upstreamError(upstream.status, upstream.headers.get("retry-after"));
}
const result = await upstream.json().catch(() => null) as ProfessionalReferenceResponse | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
await persistAppendix({ errorCode: "empty_markdown" }).catch(() => undefined);
return upstreamError(502, null);
}
await persistAppendix({ successMarkdown: result.markdown }).catch(() => undefined);
return NextResponse.json({ format: "markdown", markdown: result.markdown });
return NextResponse.json(
{ error: PERSONAL_REPORT_LEGACY_PLACEHOLDER, code: "legacy_report" },
{ status: 410 },
);
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置" }, { status: 503 });
}
console.error("professional_report_reference_failed", error instanceof Error ? error.name : "UnknownError");
return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 500 });
return NextResponse.json({ error: PERSONAL_REPORT_LEGACY_PLACEHOLDER }, { status: 500 });
}
}
@@ -6,6 +6,10 @@ import {
resolveReportRead,
} from "@/lib/personal-report-route-core";
import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server";
import {
LONGFORM_APPENDIX_TABLE,
parseLongformAppendixRow,
} from "@/lib/personal-report-longform-appendix";
import {
createSupabasePersonalReportService,
type PersonalReportService,
@@ -39,7 +43,13 @@ async function resolvePersistenceForUser() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null, listSections: undefined };
return {
userId: null as string | null,
persistence: null as PersonalReportService | null,
jobs: null,
listSections: undefined,
loadLongformMarkdown: undefined,
};
}
const persistence = createSupabasePersonalReportService(supabase);
const sections = createPersonalReportSectionService(supabase as never);
@@ -51,12 +61,23 @@ async function resolvePersistenceForUser() {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
},
loadLongformMarkdown: async (input: Readonly<{ userId: string; reportId: string }>) => {
const appendixRead = await supabase
.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
.eq("report_id", input.reportId)
.eq("user_id", input.userId)
.maybeSingle();
if (appendixRead.error) return null;
const row = parseLongformAppendixRow(appendixRead.data);
return row?.status === "ready" && row.markdown ? row.markdown : null;
},
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs, listSections } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections, loadLongformMarkdown } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -82,6 +103,7 @@ export async function GET(request: Request, context: RouteContext) {
// a substitute.
jobs: jobs ?? undefined,
listSections,
loadLongformMarkdown,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
+4
View File
@@ -45,6 +45,7 @@ const REPORT_LIST_COLUMNS = [
"failure_code",
"created_at",
"completed_at",
"card_summary:report_document->executiveSummary->>summary",
].join(",");
function sanitizedErrorCode(error: unknown): string {
@@ -76,6 +77,9 @@ function listReportView(
completedAt: row.completed_at == null || row.completed_at === ""
? null
: reportListTimestamp(row.completed_at) || null,
...(typeof row.card_summary === "string" && row.card_summary.trim()
? { cardSummary: row.card_summary.trim().slice(0, 240) }
: {}),
...(failure?.summary ? { failureSummary: failure.summary } : {}),
};
}
+84 -2
View File
@@ -3507,7 +3507,84 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
scrollbar-gutter: stable;
background: var(--color-canvas-soft);
}
.personal-report-reader-body { width: 100%; padding: 0 var(--space-6) var(--space-16); }
.personal-report-reader-body { width: min(1120px, 100%); margin: 0 auto; padding: 0 var(--space-6) var(--space-16); }
.personal-report-md-layout {
display: grid;
grid-template-columns: minmax(11rem, 15rem) minmax(0, 1fr);
gap: var(--space-6);
align-items: start;
}
.personal-report-toc {
position: sticky;
top: 72px;
max-height: calc(100vh - 96px);
overflow: auto;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.personal-report-toc ol {
margin: 0;
padding: 0;
list-style: none;
}
.personal-report-toc li[data-level="3"] { padding-left: var(--space-3); }
.personal-report-toc a {
display: block;
padding: 4px 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
text-decoration: none;
}
.personal-report-toc a.is-current,
.personal-report-toc a:hover { color: var(--color-ink); }
.personal-report-toc-drawer { display: none; }
.personal-report-toc-desktop p {
margin: 0 0 var(--space-2);
font-size: var(--type-caption);
font-weight: 600;
}
.personal-report-md-article {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-8) var(--space-7) var(--space-10);
}
.personal-report-md-article h2,
.personal-report-md-article h3 { scroll-margin-top: 80px; }
.personal-report-md-article h2 {
margin: var(--space-8) 0 var(--space-3);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
}
.personal-report-md-article h3 {
margin: var(--space-6) 0 var(--space-2);
font-size: var(--type-title-md);
font-weight: 500;
}
.personal-report-md-article p,
.personal-report-md-article li {
color: var(--color-ink);
font-size: var(--type-body-md);
line-height: 1.65;
}
.personal-report-md-placeholder { min-height: 24rem; }
.personal-report-toc-drawer summary {
min-height: 44px;
display: flex;
align-items: center;
cursor: pointer;
font-weight: 600;
}
.personal-report-md-lead,
.personal-report-md-section { min-width: 0; }
@media (max-width: 860px) {
.personal-report-md-layout { grid-template-columns: 1fr; }
.personal-report-toc { position: static; max-height: none; }
.personal-report-toc-desktop { display: none; }
.personal-report-toc-drawer { display: block; }
}
.personal-report-actions {
position: sticky;
top: 0;
@@ -3517,7 +3594,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
backdrop-filter: saturate(130%) blur(20px);
}
.personal-report-actions > div {
width: min(900px, 100%);
width: min(1120px, 100%);
min-height: 54px;
display: flex;
align-items: center;
@@ -3963,10 +4040,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
html, body { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
.personal-report-screen-only { display: none !important; }
.personal-report-toc { display: none !important; }
.personal-report-md-layout { display: block !important; }
.personal-report-print-always { display: block !important; }
.personal-report-appendix-details:not([open]) > .personal-report-print-always { display: block !important; }
.personal-report-reader { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }
.personal-report-reader-body { width: auto !important; padding: 0 !important; }
.personal-report-toc { display: none !important; }
.personal-report-md-layout { display: block !important; }
.personal-report-md-placeholder { display: none !important; }
.personal-report-document {
width: auto !important;
max-width: none !important;
@@ -8,6 +8,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import {
PERSONAL_REPORT_EXPORT_LABEL,
PERSONAL_REPORT_GENERATING_COPY,
} from "@/lib/personal-report-longform-copy";
import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download";
const LIST_POLL_INTERVAL_MS = 3000;
@@ -23,6 +27,7 @@ type ReportListItem = Readonly<{
failureSummary?: string | null;
createdAt: string;
completedAt: string | null;
cardSummary?: string | null;
}>;
type CenterState =
@@ -32,8 +37,8 @@ type CenterState =
| { phase: "error"; reports: readonly ReportListItem[] };
const STATUS_COPY = {
generating: { label: "生成中", description: "已在后台处理,你可以离开此页面。" },
ready: { label: "已完成", description: "报告已生成,可随时查看或保存为 PDF。" },
generating: { label: "生成中", description: PERSONAL_REPORT_GENERATING_COPY },
ready: { label: "已完成", description: "报告已生成,可随时查看或导出 Markdown。" },
failed: { label: "未完成", description: "本次生成没有产出可用报告,可重新生成。" },
} as const;
@@ -61,7 +66,10 @@ function readReports(value: unknown): ReportListItem[] {
failureCode: typeof row.failureCode === "string" ? row.failureCode : null,
failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null,
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
completedAt: typeof row.completedAt === "string" ? row.completedAt : null,
completedAt: row.completedAt == null ? null : String(row.completedAt),
cardSummary: typeof row.cardSummary === "string" && row.cardSummary.trim()
? row.cardSummary
: null,
}];
});
}
@@ -139,15 +147,17 @@ export function PersonalReportCenter() {
[state.reports],
);
const downloadProfessionalReference = useCallback(async (reportId: string) => {
setExportingReportId(reportId);
const downloadProfessionalReference = useCallback(async (report: ReportListItem) => {
setExportingReportId(report.id);
setExportError(null);
try {
await downloadPersonalReportLongformAppendix(reportId);
await downloadPersonalReportLongformAppendix(report.id, {
reportDate: report.completedAt ?? report.createdAt,
});
} catch (error) {
setExportError({
reportId,
message: error instanceof Error ? error.message : "全量数据附录暂不可用",
reportId: report.id,
message: error instanceof Error ? error.message : "报告导出暂不可用",
});
} finally {
setExportingReportId(null);
@@ -215,7 +225,7 @@ export function PersonalReportCenter() {
<StatusIcon status={report.status} />{copy.label}
</div>
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
<p>{copy.description}</p>
<p>{report.status === "ready" && report.cardSummary ? report.cardSummary : copy.description}</p>
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
@@ -227,10 +237,10 @@ export function PersonalReportCenter() {
type="button"
variant="ghost"
disabled={exportingReportId === report.id}
onClick={() => void downloadProfessionalReference(report.id)}
onClick={() => void downloadProfessionalReference(report)}
>
{exportingReportId === report.id ? <InlineSpinner size={14} /> : null}
{PERSONAL_REPORT_EXPORT_LABEL}
</Button>
{exportError?.reportId === report.id ? (
<span className="report-center-export-error" role="alert">
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import {
buildLongformOutline,
type LongformHeading,
type LongformSection,
} from "@/lib/personal-report-longform-outline";
function flattenText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(flattenText).join("");
if (node && typeof node === "object" && "props" in node) {
const props = (node as { props?: { children?: ReactNode } }).props;
return flattenText(props?.children);
}
return "";
}
function reportUrlTransform(url: string): string {
return url.startsWith("#") ? url : "";
}
function markdownComponents(headings: readonly LongformHeading[]): Components {
const cursor = { h2: 0, h3: 0 };
const nextId = (level: 2 | 3, children: ReactNode) => {
const title = flattenText(children).trim();
const key = level === 2 ? "h2" : "h3";
const matches = headings.filter((heading) => heading.level === level && heading.title === title);
const index = cursor[key];
cursor[key] = index + 1;
return matches[index]?.id ?? matches[0]?.id;
};
return {
a: ({ children, href }) => {
if (typeof href === "string" && href.startsWith("#")) {
return <a href={href}>{children}</a>;
}
return <span>{children}</span>;
},
img: () => null,
table: ({ children }) => (
<div className="markdown-table personal-report-table-wrap">
<table>{children}</table>
</div>
),
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
};
}
function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) {
return (
<ReactMarkdown
components={markdownComponents(headings)}
disallowedElements={["script", "iframe", "object", "embed", "img"]}
remarkPlugins={[remarkGfm]}
skipHtml
unwrapDisallowed
urlTransform={reportUrlTransform}
>
{markdown}
</ReactMarkdown>
);
}
function LazyMarkdownSection({
section,
}: {
section: LongformSection;
}) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(section.eager);
useEffect(() => {
if (visible) return;
if (typeof IntersectionObserver === "undefined") {
setVisible(true);
return;
}
if (!ref.current) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
}, { rootMargin: "280px 0px" });
observer.observe(ref.current);
return () => observer.disconnect();
}, [visible]);
return (
<section ref={ref} className="personal-report-md-section">
{visible ? renderMarkdown(section.markdown, section.headings) : (
<h2 id={section.id}>{section.title}</h2>
)}
</section>
);
}
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? "");
useEffect(() => {
if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top);
const id = visible[0]?.target.id;
if (id) setActiveId(id);
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
for (const item of outline.headings) {
const node = document.getElementById(item.id);
if (node) observer.observe(node);
}
return () => observer.disconnect();
}, [outline.headings]);
return (
<div className="personal-report-md-layout">
<nav aria-label="报告目录" className="personal-report-toc">
<details className="personal-report-toc-drawer">
<summary></summary>
<TocList activeId={activeId} items={outline.headings} />
</details>
<div className="personal-report-toc-desktop">
<p></p>
<TocList activeId={activeId} items={outline.headings} />
</div>
</nav>
<article className="personal-report-document personal-report-md-article">
{outline.leadMarkdown ? (
<section className="personal-report-md-lead">
{renderMarkdown(outline.leadMarkdown, outline.headings)}
</section>
) : null}
{outline.sections.filter((section) => !section.eager).map((section) => (
<LazyMarkdownSection key={section.id} section={section} />
))}
</article>
</div>
);
}
function TocList({
items,
activeId,
}: {
items: readonly LongformHeading[];
activeId: string;
}) {
return (
<ol>
{items.map((item) => (
<li data-level={item.level} key={item.id}>
<a
aria-current={item.id === activeId ? "location" : undefined}
className={item.id === activeId ? "is-current" : undefined}
href={`#${item.id}`}
>
{item.title}
</a>
</li>
))}
</ol>
);
}
@@ -2,14 +2,9 @@
* Client loader for /reports/[reportId].
*
* Fetches GET /api/reports/:id (same-origin, cookies included) and maps the
* envelope to explicit UI states: loading / unauthorized / not-found /
* generating (with polling) / timed-out (poll budget exhausted, generation
* continues server-side) / failed / invalid (schema guard rejected) / ready.
* The print action is mounted only after a validated ready document exists.
*
* The GET envelope is classified here by status discriminant only; the ready
* reportDocument payload itself is validated by the canonical
* safeParseReportDocument from @/lib/personal-report-contract.
* envelope to explicit UI states. Ready reports render longform Markdown.
* Missing Markdown shows the retired-writer placeholder. Print stays a
* secondary action after the Markdown body exists.
*/
"use client";
@@ -23,9 +18,11 @@ import { Clock3, TriangleAlert } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { ReportActions } from "./report-actions";
import { PersonalReportDocumentView } from "./personal-report-document-view";
import { safeParseReportDocument } from "@/lib/personal-report-contract";
import type { ReportDocument } from "@/lib/personal-report-contract";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import {
PERSONAL_REPORT_GENERATING_COPY,
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "@/lib/personal-report-longform-copy";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -38,7 +35,8 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "ready"; document: ReportDocument };
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
export interface ReportEnvelopeView {
@@ -57,11 +55,11 @@ export interface ReportEnvelopeView {
/**
* Status discriminant for the GET /api/reports/:id envelope, aligned to the
* actual route response: `{ report: { status, failureCode, ... }, reportDocument? }`
* with 401/404/403/5xx error envelopes. Does NOT validate reportDocument here;
* ready payloads are passed to the canonical safeParseReportDocument from
* @/lib/personal-report-contract. The timed-out phase is client-only: the
* server never reports it, it is reached when the local poll budget runs out.
* actual route response: `{ report: { status, failureCode, ... }, longformMarkdown? }`
* with 401/404/403/5xx error envelopes. Ready reports render the longform
* Markdown body; missing Markdown is treated as a retired writer-era report.
* The timed-out phase is client-only: the server never reports it, it is
* reached when the local poll budget runs out.
*/
export function classifyReportEnvelope(statusCode: number, json: unknown): ReportLoadState {
if (statusCode === 401) {
@@ -83,14 +81,16 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const view = json.report as Partial<ReportEnvelopeView>;
switch (view.status) {
case "ready": {
if (!("reportDocument" in json)) {
return { phase: "invalid", message: "报告接口缺少报告正文。" };
const markdown = typeof json.longformMarkdown === "string" ? json.longformMarkdown.trim() : "";
if (markdown.length > 0) {
return {
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
createdAt: typeof view.createdAt === "string" ? view.createdAt : "",
};
}
const parsed = safeParseReportDocument(json.reportDocument);
if (!parsed.ok) {
return { phase: "invalid", message: parsed.errors.map((error) => `${error.path}: ${error.message}`).join("; ") };
}
return { phase: "ready", document: parsed.document };
return { phase: "legacy-unavailable" };
}
case "generating": {
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
@@ -209,11 +209,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
}, [load]);
const generating = state.phase === "generating";
const progressLabel = generating && state.progressPhase?.startsWith("section:")
? `正在生成主题(进度 ${Math.max(0, Math.min(100, Math.round(state.progressPercent ?? 30)))}%`
: generating && state.progressPhase === "summary" ? "正在汇总全部主题"
: generating && state.progressPhase === "assemble" ? "正在装配报告"
: null;
useVisibilityAwarePoll({
enabled: generating,
@@ -232,7 +227,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">
{generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"}
{generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}
</p>
{generating && (
<>
@@ -321,13 +316,43 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
);
}
const document = state.document;
if (state.phase === "legacy-unavailable") {
return (
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1></h1>
<p>{PERSONAL_REPORT_LEGACY_PLACEHOLDER}</p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
</Button>
</main>
);
}
if (state.phase !== "markdown-ready") {
return (
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1></h1>
<p></p>
<Button type="button" variant="outline" onClick={() => void reload()}>
</Button>
</main>
);
}
return (
<main className="personal-report-reader">
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
<ReportActions reportId={document.reportId} reportTitle={`${document.subject.displayName} · 个人报告`} />
<ReportActions
reportId={state.reportId || reportId}
reportTitle="个人报告"
markdown={state.markdown}
reportDate={state.createdAt}
/>
<div className="personal-report-reader-body">
<PersonalReportDocumentView document={document} />
<PersonalReportMarkdownView markdown={state.markdown} />
</div>
</main>
);
@@ -15,11 +15,17 @@ import {
safeReportFilename,
} from "@/lib/client-report-export";
import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download";
import {
PERSONAL_REPORT_EXPORT_LABEL,
PERSONAL_REPORT_PRINT_LABEL,
} from "@/lib/personal-report-longform-copy";
import { Button } from "@/components/ui/button";
interface ReportActionsProps {
reportId: string;
reportTitle?: string;
markdown?: string;
reportDate?: string | null;
}
function subscribePrintCapability(onStoreChange: () => void): () => void {
@@ -27,14 +33,19 @@ function subscribePrintCapability(onStoreChange: () => void): () => void {
return () => undefined;
}
export function ReportActions({ reportId, reportTitle }: ReportActionsProps) {
export function ReportActions({
reportId,
reportTitle,
markdown,
reportDate,
}: ReportActionsProps) {
const [printBusy, setPrintBusy] = useState(false);
const [appendixBusy, setAppendixBusy] = useState(false);
const [exportBusy, setExportBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const printSupported = useSyncExternalStore(subscribePrintCapability, isPrintSupported, () => false);
async function handlePrint() {
if (printBusy || appendixBusy || !printSupported) return;
if (printBusy || exportBusy || !printSupported) return;
const restriction = detectPrintRestriction();
if (restriction.restricted) {
setNotice(restriction.message ?? "当前浏览器无法可靠打印,请在系统浏览器中打开本页。");
@@ -49,16 +60,19 @@ export function ReportActions({ reportId, reportTitle }: ReportActionsProps) {
}
}
async function handleAppendix() {
if (printBusy || appendixBusy) return;
setAppendixBusy(true);
async function handleExport() {
if (printBusy || exportBusy) return;
setExportBusy(true);
setNotice(null);
try {
await downloadPersonalReportLongformAppendix(reportId);
await downloadPersonalReportLongformAppendix(reportId, {
markdown,
reportDate,
});
} catch (error) {
setNotice(error instanceof Error ? error.message : "全量数据附录暂不可");
setNotice(error instanceof Error ? error.message : "报告暂不可导出");
} finally {
setAppendixBusy(false);
setExportBusy(false);
}
}
@@ -70,14 +84,14 @@ export function ReportActions({ reportId, reportTitle }: ReportActionsProps) {
</Link>
<div className="personal-report-action-end">
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
<Button type="button" size="sm" variant="ghost" onClick={() => void handleAppendix()} disabled={printBusy || appendixBusy}>
<FileText aria-hidden="true" />{appendixBusy ? "正在准备附录…" : "全量数据附录"}
<Button type="button" size="sm" variant="ghost" onClick={() => void handlePrint()} disabled={printBusy || exportBusy || !printSupported}>
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : PERSONAL_REPORT_PRINT_LABEL}
</Button>
<Button type="button" size="sm" onClick={() => void handlePrint()} disabled={printBusy || appendixBusy || !printSupported}>
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : "打印 / 保存为 PDF"}
<Button type="button" size="sm" onClick={() => void handleExport()} disabled={printBusy || exportBusy}>
<FileText aria-hidden="true" />{exportBusy ? "正在准备…" : PERSONAL_REPORT_EXPORT_LABEL}
</Button>
</div>
</div>
</nav>
);
}
}
@@ -0,0 +1,91 @@
import {
globalBirthProfileFromAccountRow,
} from "./server-owned-birth-profile";
export function utcToday(now = new Date()): string {
return now.toISOString().slice(0, 10);
}
function accountText(row: unknown, key: string): string | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
const value = (row as Record<string, unknown>)[key];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function clockToMinutes(value: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10);
}
export function provenanceUncertaintyMinutes(
row: unknown,
candidateRange: { start_time: string; end_time: string } | null,
): number | undefined {
if (candidateRange) {
const start = clockToMinutes(candidateRange.start_time);
const end = clockToMinutes(candidateRange.end_time);
if (start !== null && end !== null) return Math.max(0, end - start);
}
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
const record = row as Record<string, unknown>;
const before = typeof record.uncertainty_before_minutes === "number"
? record.uncertainty_before_minutes
: Number(record.uncertainty_before_minutes);
const after = typeof record.uncertainty_after_minutes === "number"
? record.uncertainty_after_minutes
: Number(record.uncertainty_after_minutes);
const hasBefore = Number.isFinite(before);
const hasAfter = Number.isFinite(after);
if (!hasBefore && !hasAfter) return undefined;
return Math.max(0, (hasBefore ? before : 0) + (hasAfter ? after : 0));
}
export function buildLongformBirthPayload(
row: unknown,
extras: Readonly<{
today: string;
targetYear: number;
candidateRange: { start_time: string; end_time: string } | null;
birthTimeAccuracy: "confirmed" | "provisional";
}>,
): Record<string, unknown> | null {
const profile = globalBirthProfileFromAccountRow(row);
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? "");
if (
!dateMatch
|| !timeMatch
|| profile.latitude === null
|| profile.longitude === null
|| profile.timezoneOffset === null
) {
return null;
}
const year = Number.parseInt(dateMatch[1], 10);
return {
year,
month: Number.parseInt(dateMatch[2], 10),
day: Number.parseInt(dateMatch[3], 10),
hour: Number.parseInt(timeMatch[1], 10),
minute: Number.parseInt(timeMatch[2], 10),
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
ayanamsa: profile.ayanamsa,
format: "markdown",
packs: ["full"],
today: extras.today,
target_year: extras.targetYear,
age: extras.targetYear - year,
birth_time_accuracy: extras.birthTimeAccuracy,
...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}),
birthplace_label: accountText(row, "birth_place_label") ?? undefined,
coordinate_source: "user_reported",
coordinate_precision: "unverified_user_coordinates",
time_source: accountText(row, "birth_time_source") ?? accountText(row, "birth_time_status") ?? undefined,
uncertainty_minutes: provenanceUncertaintyMinutes(row, extras.candidateRange),
};
}
@@ -0,0 +1,4 @@
export const PERSONAL_REPORT_LEGACY_PLACEHOLDER = "旧版本报告,请重新生成";
export const PERSONAL_REPORT_GENERATING_COPY = "正在生成报告,大约 1030 秒";
export const PERSONAL_REPORT_EXPORT_LABEL = "导出报告(.md";
export const PERSONAL_REPORT_PRINT_LABEL = "打印";
@@ -0,0 +1,142 @@
import { createHash } from "node:crypto";
import { computeEvidenceHash, parseServerReportDocument } from "./personal-report-contract.server-core.ts";
import {
REPORT_DOCUMENT_V2_SCHEMA_VERSION,
type ReportDocumentV2,
} from "./personal-report-contract.ts";
import { extractLongformSummary } from "./personal-report-longform-outline.ts";
import type { PersonalReportRecord } from "./personal-report-service-core.ts";
type CoverSkillSnapshot = Readonly<{
name: string;
version: string;
sha256: string;
sourceCommit: string | null;
}>;
const COVER_EVIDENCE_ID = "ev-cover-longform";
const SIGNS_CN = [
"白羊座", "金牛座", "双子座", "巨蟹座", "狮子座", "处女座",
"天秤座", "天蝎座", "射手座", "摩羯座", "水瓶座", "双鱼座",
] as const;
export type LongformCoverInput = Readonly<{
report: Pick<
PersonalReportRecord,
| "id"
| "reportType"
| "presentationMode"
| "depth"
| "requestedThemes"
| "skillName"
| "skillVersion"
| "skillSourceCommit"
| "skillSnapshotSha256"
>;
subject: Readonly<{
displayName: string;
birthTimeStatus: ReportDocumentV2["subject"]["birthTimeStatus"];
birthPlaceLabel: string;
}>;
markdown: string;
generatedAt?: string;
skillSnapshot?: CoverSkillSnapshot;
}>;
function coverHouses(): ReportDocumentV2["charts"][number]["houses"] {
return SIGNS_CN.map((sign, index) => ({
houseNumber: index + 1,
sign,
occupants: [],
}));
}
export function buildLongformCoverDocument(input: LongformCoverInput): ReportDocumentV2 {
const summary = extractLongformSummary(input.markdown);
const skillName = input.skillSnapshot?.name ?? input.report.skillName;
const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion;
const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256;
const skillSourceCommit = input.skillSnapshot?.sourceCommit ?? input.report.skillSourceCommit;
if (!skillName || !skillVersion || !skillSnapshotSha256) {
throw new Error("report_schema_invalid");
}
const calculationHash = createHash("sha256").update(input.markdown, "utf8").digest("hex");
const evidenceAppendix: ReportDocumentV2["evidenceAppendix"] = {
expandedByDefault: false,
techniqueAudit: [{
id: COVER_EVIDENCE_ID,
techniqueId: "pl9_personal_long_report",
techniqueName: "个人长报告 Markdown",
status: "partial",
used: true,
notes: "正文为长报告 Markdown,本封面仅用于落库校验。",
}],
conflicts: [],
calculationEvidence: [],
blockedTechniques: [],
};
const document = {
schemaVersion: REPORT_DOCUMENT_V2_SCHEMA_VERSION,
reportId: input.report.id,
reportType: input.report.reportType,
presentationMode: input.report.presentationMode,
depth: input.report.depth,
requestedThemes: [...input.report.requestedThemes],
generatedAt: input.generatedAt ?? new Date().toISOString(),
subject: input.subject,
provenance: {
skillName,
skillVersion,
skillSourceCommit: skillSourceCommit && /^[0-9a-f]{40}$/.test(skillSourceCommit)
? skillSourceCommit
: null,
skillSnapshotSha256,
calculationHash,
evidenceHash: computeEvidenceHash(evidenceAppendix),
reportContractVersion: "2" as const,
},
executiveSummary: {
headline: "个人长报告已生成",
summary,
priorities: ["阅读长报告 Markdown 正文"],
overallClaimStatus: "parameter_sensitive" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
},
natalFoundation: {
title: "封面占位",
narrative: "本封面不作为阅读正文。完整报告见长报告 Markdown。",
keyFactors: ["长报告 Markdown 为正文"],
caveats: ["本封面文档不在详情页渲染"],
claimStatus: "parameter_sensitive" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
},
currentPhase: null,
actionNotes: [{
id: "read-longform",
title: "阅读长报告",
note: "请在详情页阅读长报告 Markdown 正文。",
priority: "now" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
charts: [{
id: "D1" as const,
title: "D1 占位",
houses: coverHouses(),
claimStatus: "parameter_sensitive" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
thematicNarrative: [],
blockedConflictDisclosure: input.report.requestedThemes.map((theme) => ({
theme,
title: `${theme} 主题见长报告`,
reason: "本封面不展开主题叙事。完整证据与解读见长报告 Markdown。",
missingEvidence: ["封面不装配主题叙事,请阅读长报告正文"],
conflictNotes: [],
evidenceRefs: [] as string[],
claimStatus: "blocked" as const,
})),
evidenceAppendix,
disclaimer: "本封面仅用于系统落库校验。阅读请以长报告 Markdown 为准。",
};
return parseServerReportDocument(document) as ReportDocumentV2;
}
@@ -1,4 +1,6 @@
import { downloadMarkdownReport } from "./consultation-report-export";
import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy";
import { personalReportMarkdownFilename } from "./personal-report-longform-outline";
export async function requestPersonalReportLongformAppendix(reportId: string): Promise<string> {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, {
@@ -14,12 +16,19 @@ export async function requestPersonalReportLongformAppendix(reportId: string): P
|| typeof payload.markdown !== "string"
|| !payload.markdown.trim()
) {
throw new Error(typeof payload.error === "string" ? payload.error : "全量数据附录暂不可用");
throw new Error(
typeof payload.error === "string" ? payload.error : PERSONAL_REPORT_LEGACY_PLACEHOLDER,
);
}
return payload.markdown;
}
export async function downloadPersonalReportLongformAppendix(reportId: string): Promise<void> {
const markdown = await requestPersonalReportLongformAppendix(reportId);
downloadMarkdownReport("个人全量数据附录", markdown);
export async function downloadPersonalReportLongformAppendix(
reportId: string,
options: Readonly<{ markdown?: string; reportDate?: string | null }> = {},
): Promise<void> {
const markdown = options.markdown?.trim()
? options.markdown
: await requestPersonalReportLongformAppendix(reportId);
downloadMarkdownReport(personalReportMarkdownFilename(options.reportDate), markdown);
}
@@ -0,0 +1,223 @@
import "server-only";
import {
LONGFORM_APPENDIX_TABLE,
nextLongformAppendixState,
parseLongformAppendixRow,
type LongformAppendixRow,
} from "./personal-report-longform-appendix";
import { buildLongformBirthPayload, utcToday } from "./personal-report-longform-birth";
import { buildLongformCoverDocument } from "./personal-report-longform-cover";
import type { GeneratePersonalReportResult } from "./personal-report-generation";
import type { PersonalReportRecord } from "./personal-report-service-core";
import type { ReportDocumentV2 } from "./personal-report-contract";
const APPENDIX_SELECT =
"report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code";
const DEFAULT_API_BASE = "http://127.0.0.1:5200";
const ENGINE_TIMEOUT_MS = 180_000;
export class LongformGenerateError extends Error {
readonly name = "LongformGenerateError";
constructor(
readonly code: "calculation_unavailable" | "report_schema_invalid" | "birth_time_not_usable",
readonly retryable: boolean,
message?: string,
) {
super(message ?? code);
}
}
type JsonRecord = Record<string, unknown>;
export type AppendixClient = {
from(table: string): {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
};
upsert(row: JsonRecord): PromiseLike<{ error: { message?: string } | null }>;
};
};
type AppendixFilter = {
eq(column: string, value: unknown): AppendixFilter;
maybeSingle(): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
export type LongformGenerateDeps = Readonly<{
report: PersonalReportRecord;
profile: unknown;
candidateRange: Readonly<{ startTime: string; endTime: string }> | null;
admin: AppendixClient;
displayName: string;
birthTimeStatus: ReportDocumentV2["subject"]["birthTimeStatus"];
apiBase?: string;
fetchImpl?: typeof fetch;
now?: () => Date;
signal?: AbortSignal;
}>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function readOwnedLongformAppendix(
client: AppendixClient,
reportId: string,
userId: string,
): Promise<LongformAppendixRow | null> {
const result = await client
.from(LONGFORM_APPENDIX_TABLE)
.select(APPENDIX_SELECT)
.eq("report_id", reportId)
.eq("user_id", userId)
.maybeSingle();
if (result.error) return null;
return parseLongformAppendixRow(result.data);
}
export async function persistLongformAppendix(input: Readonly<{
admin: AppendixClient;
reportId: string;
userId: string;
requestId: string;
current: LongformAppendixRow | null;
successMarkdown?: string | null;
errorCode?: string | null;
}>): Promise<void> {
const next = nextLongformAppendixState({
current: input.current,
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: input.reportId,
user_id: input.userId,
request_id: input.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
});
if (result.error) {
throw new LongformGenerateError("calculation_unavailable", true, "longform_appendix_persist_failed");
}
}
async function fetchLongformMarkdown(input: Readonly<{
payload: Record<string, unknown>;
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
}>): Promise<string> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
: timeout;
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
cache: "no-store",
signal,
});
if (!upstream.ok) {
throw new LongformGenerateError(
"calculation_unavailable",
true,
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
}
export async function generatePersonalReportLongform(
deps: LongformGenerateDeps,
): Promise<GeneratePersonalReportResult> {
const profile = record(deps.profile);
if (!profile) {
throw new LongformGenerateError("report_schema_invalid", false);
}
const today = utcToday(deps.now?.() ?? new Date());
const range = deps.candidateRange
? { start_time: deps.candidateRange.startTime, end_time: deps.candidateRange.endTime }
: null;
const payload = buildLongformBirthPayload(deps.profile, {
today,
targetYear: Number(today.slice(0, 4)),
candidateRange: range,
birthTimeAccuracy: range && range.start_time !== range.end_time ? "provisional" : "confirmed",
});
if (!payload) {
throw new LongformGenerateError("birth_time_not_usable", false);
}
const current = await readOwnedLongformAppendix(deps.admin, deps.report.id, deps.report.userId);
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
if (!markdown) {
try {
markdown = await fetchLongformMarkdown({
payload,
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
});
} catch (error) {
if (deps.signal?.aborted) throw error;
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
errorCode: error instanceof LongformGenerateError ? error.message.slice(0, 80) : "generation_failed",
}).catch(() => undefined);
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true);
}
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
successMarkdown: markdown,
});
}
try {
const document = buildLongformCoverDocument({
report: deps.report,
subject: {
displayName: deps.displayName,
birthTimeStatus: deps.birthTimeStatus,
birthPlaceLabel: text(profile.birth_place_label) ?? "未知出生地",
},
markdown,
generatedAt: (deps.now?.() ?? new Date()).toISOString(),
});
return {
status: "ready",
document,
evidenceHash: document.provenance.evidenceHash,
};
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "final_parse_rejected");
}
}
@@ -0,0 +1,179 @@
export type LongformHeadingLevel = 2 | 3;
export type LongformHeading = Readonly<{
id: string;
level: LongformHeadingLevel;
title: string;
}>;
export type LongformSection = Readonly<{
id: string;
title: string;
markdown: string;
headings: readonly LongformHeading[];
eager: boolean;
}>;
export type LongformOutline = Readonly<{
headings: readonly LongformHeading[];
leadMarkdown: string;
sections: readonly LongformSection[];
}>;
const EAGER_H2 = /成品阅读导航|质量验收矩阵/;
const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/;
export function slugifyHeading(title: string, used: Map<string, number>): string {
const base = title
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "section";
const next = (used.get(base) ?? 0) + 1;
used.set(base, next);
return next === 1 ? base : `${base}-${next}`;
}
function parseHeading(line: string): { level: LongformHeadingLevel; title: string } | null {
const match = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
if (!match) return null;
return {
level: match[1].length === 2 ? 2 : 3,
title: match[2].replace(/\s+#+\s*$/, "").trim(),
};
}
function extractHeadingBlock(markdown: string, pattern: RegExp, level: LongformHeadingLevel): string | null {
const lines = markdown.split("\n");
let start = -1;
for (let index = 0; index < lines.length; index += 1) {
const heading = parseHeading(lines[index] ?? "");
if (heading?.level === level && pattern.test(heading.title)) {
start = index;
break;
}
}
if (start < 0) return null;
const collected = [lines[start]];
for (let index = start + 1; index < lines.length; index += 1) {
const heading = parseHeading(lines[index] ?? "");
if (heading && heading.level <= level) break;
collected.push(lines[index] ?? "");
}
const text = collected.join("\n").trim();
return text.length > 0 ? text : null;
}
function extractH3Block(markdown: string, pattern: RegExp): string | null {
return extractHeadingBlock(markdown, pattern, 3);
}
function stripH3Block(markdown: string, pattern: RegExp): string {
const lines = markdown.split("\n");
const kept: string[] = [];
let skipping = false;
for (const line of lines) {
const heading = parseHeading(line);
if (skipping) {
if (heading && heading.level <= 3) skipping = false;
else continue;
}
if (!skipping && heading?.level === 3 && pattern.test(heading.title)) {
skipping = true;
continue;
}
if (!skipping) kept.push(line);
}
return kept.join("\n").replace(/^\n+|\n+$/g, "");
}
export function buildLongformOutline(markdown: string): LongformOutline {
const used = new Map<string, number>();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const headings: LongformHeading[] = [];
type Block = {
title: string;
start: number;
headingIndex: number;
h3Indexes: number[];
};
const blocks: Block[] = [];
let current: Block | null = null;
lines.forEach((line, lineIndex) => {
const parsed = parseHeading(line);
if (!parsed) return;
const heading: LongformHeading = {
id: slugifyHeading(parsed.title, used),
level: parsed.level,
title: parsed.title,
};
const headingIndex = headings.length;
headings.push(heading);
if (parsed.level === 2) {
current = {
title: parsed.title,
start: lineIndex,
headingIndex,
h3Indexes: [],
};
blocks.push(current);
return;
}
if (current) current.h3Indexes.push(headingIndex);
});
const leadParts: string[] = [];
const preamble = (blocks[0] ? lines.slice(0, blocks[0].start) : lines).join("\n").trim();
if (preamble) leadParts.push(preamble);
const sections: LongformSection[] = blocks.map((block, index) => {
const end = index + 1 < blocks.length ? blocks[index + 1].start : lines.length;
const raw = lines.slice(block.start, end).join("\n").trim();
const eager = EAGER_H2.test(block.title) || SUMMARY_TITLE.test(block.title);
const summary = extractH3Block(raw, SUMMARY_TITLE);
if (eager) leadParts.push(raw);
else if (summary) leadParts.push(summary);
const sectionMarkdown = eager || !summary ? raw : stripH3Block(raw, SUMMARY_TITLE);
return {
id: headings[block.headingIndex]?.id ?? slugifyHeading(block.title, used),
title: block.title,
markdown: sectionMarkdown,
headings: [
headings[block.headingIndex],
...block.h3Indexes.map((item) => headings[item]),
].filter((item): item is LongformHeading => Boolean(item)),
eager,
};
});
return {
headings,
leadMarkdown: leadParts.join("\n\n").trim(),
sections,
};
}
export function extractLongformSummary(markdown: string, maxLength = 2000): string {
const summaryBlock = extractH3Block(markdown, SUMMARY_TITLE)
?? extractHeadingBlock(markdown, SUMMARY_TITLE, 2)
?? "";
const cleaned = summaryBlock
.replace(/^#{1,6}\s+.+$/gm, " ")
.replace(/<[^>]*>/g, " ")
.replace(/[#>*`|_\[\]]/g, " ")
.replace(/\s+/g, " ")
.trim();
const fallback = "个人长报告已生成。详情页展示完整 Markdown 正文。";
const text = cleaned.length > 0 ? cleaned : fallback;
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1)}`;
}
export function personalReportMarkdownFilename(reportDate: string | null | undefined): string {
const stamp = typeof reportDate === "string" && /^\d{4}-\d{2}-\d{2}/.test(reportDate)
? reportDate.slice(0, 10)
: new Date().toISOString().slice(0, 10);
return `个人报告-${stamp}`.replace(/[\\/:*?"<>|]+/g, "-");
}
+27 -1
View File
@@ -651,6 +651,10 @@ export type ReportReadCoreDeps = Readonly<{
status: string;
lastErrorCode: string | null;
}>[]>;
loadLongformMarkdown?: (input: Readonly<{
userId: string;
reportId: string;
}>) => Promise<string | null>;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
@@ -680,10 +684,32 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
})
: null;
if (row.status === "ready") {
const markdown = deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined;
if (typeof markdown === "string" && markdown.trim()) {
const validated = deps.validateReadyDocument(row.reportDocument);
return {
status: 200,
body: {
report: reportView(row, job),
longformMarkdown: markdown,
...(validated.ok ? { reportDocument: validated.document } : {}),
},
};
}
// Re-validate the stored document through the canonical server parse
// before it is allowed to leave the server; an invalid stored document is
// surfaced as a stable failure WITHOUT the document body.
// surfaced as a stable failure WITHOUT the document body. When the MD
// loader is wired and the appendix is missing, the client shows the
// legacy placeholder instead of the five-chapter document.
const validated = deps.validateReadyDocument(row.reportDocument);
if (deps.loadLongformMarkdown) {
return {
status: 200,
body: { report: reportView(row, job), longformMarkdown: null },
};
}
if (!validated.ok) {
return {
status: 422,
@@ -370,18 +370,23 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.persistingReport,
});
const settlementModelId = generated.usage?.actualModelId?.trim() ?? "";
if (deps.billing && (!settlementModelId || settlementModelId === "unknown")) {
throw new PersonalReportWorkerError("model_unavailable", true, "missing settlement model id");
}
await deps.jobs.completeReportReady({
jobId: job.id,
leaseToken: job.leaseToken,
report,
document: generated.document,
});
report = { ...report, status: "ready" };
if (deps.billing) {
const settled = await deps.billing.complete({
userId: job.userId,
requestId: job.requestId,
usage: {
actualModelId: generated.usage?.actualModelId ?? "unknown",
actualModelId: settlementModelId,
modelConfigVersion: generated.usage?.modelConfigVersion,
inputTokens: generated.usage?.inputTokens ?? 0,
outputTokens: generated.usage?.outputTokens ?? 0,
+68 -3
View File
@@ -21,6 +21,12 @@ import {
resolveReportBirthClock,
resolveReportBirthTimeSensitivityInput,
} from "@/lib/personal-report-route-core";
import {
generatePersonalReportLongform,
LongformGenerateError,
type AppendixClient,
} from "@/lib/personal-report-longform-generate";
import { PERSONAL_REPORT_WRITER_ENABLED } from "@/lib/personal-report-writer-flag";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { completeUsage, releaseUsage } from "@/lib/consultation-billing";
@@ -97,7 +103,7 @@ function skillSnapshotForReport(context: PersonalReportWorkerGenerationContext):
};
}
async function generateProductionReport(
async function generateWriterReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null = null,
) {
@@ -206,6 +212,65 @@ async function generateProductionReport(
};
}
async function generateLongformReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null,
admin: AppendixClient,
) {
const profile = record(context.profile);
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
const usableBirth = resolveReportBirthClock(profile);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
const displayName = text(profile.name) ?? "我的报告";
if (!usableBirth || latitude === null || longitude === null || timezoneOffset === null) {
throw new PersonalReportWorkerError("birth_time_not_usable", false);
}
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
if (!model) throw new PersonalReportWorkerError("model_unavailable", true);
try {
const generated = await generatePersonalReportLongform({
report: context.report,
profile,
candidateRange,
admin,
displayName,
birthTimeStatus: usableBirth.status,
signal: context.signal,
});
if (generated.status !== "ready") return generated;
return {
...generated,
usage: {
inputTokens: 0,
outputTokens: 0,
actualModelId: model.id,
modelConfigVersion: model.configVersion,
},
};
} catch (error) {
if (context.signal.aborted) throw error;
if (error instanceof PersonalReportWorkerError) throw error;
if (error instanceof LongformGenerateError) {
throw new PersonalReportWorkerError(error.code, error.retryable, error.message);
}
throw new PersonalReportWorkerError("calculation_unavailable", true);
}
}
async function generateProductionReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null,
admin: AppendixClient,
) {
if (PERSONAL_REPORT_WRITER_ENABLED) {
return generateWriterReport(context, candidateRange);
}
return generateLongformReport(context, candidateRange, admin);
}
function createProductionWorker(workerId: string) {
const admin = createAdminSupabaseClient();
const backend = admin as unknown as {
@@ -255,13 +320,13 @@ function createProductionWorker(workerId: string) {
generate: async (context) => {
const profile = record(context.profile) ?? {};
if (resolveReportBirthClock(profile)?.status === "confirmed") {
return generateProductionReport(context);
return generateProductionReport(context, null, admin as never);
}
const range = await loadReportCandidateRange(admin, {
userId: context.report.userId,
rectificationCaseId: text(profile.rectification_case_id),
});
return generateProductionReport(context, range);
return generateProductionReport(context, range, admin as never);
},
});
}
@@ -0,0 +1,2 @@
/** Writer narrative pipeline is retired for new reports. Keep the code; do not call it. */
export const PERSONAL_REPORT_WRITER_ENABLED = false;
@@ -4,7 +4,7 @@ import { resolveAyanamsa } from "./ayanamsa.ts";
const usableActiveStatuses = new Set(["accepted", "confirmed"]);
export const ACCOUNT_BIRTH_SELECT =
"name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa" as const;
"name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,birth_time_source,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa,birth_place_label,uncertainty_before_minutes,uncertainty_after_minutes,declared_window_start,declared_window_end" as const;
export type AccountBirthRow = Readonly<{
name?: unknown;
@@ -13,6 +13,12 @@ export type AccountBirthRow = Readonly<{
active_birth_time?: unknown;
birth_time?: unknown;
birth_time_status?: unknown;
birth_time_source?: unknown;
birth_place_label?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
declared_window_start?: unknown;
declared_window_end?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;