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;