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:
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user