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;
|
||||
|
||||
@@ -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 = "正在生成报告,大约 10–30 秒";
|
||||
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, "-");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user