diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 29c2c943..ecac26c2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8629,4 +8629,36 @@ - 复发自:BUG-553(`a1956deb` 的测试没断言守卫会命中) - 修复版本:`0102a973` +## BUG-561 | 长报告 KP Lord/Sub 与专题 KP 表在失败信封下整段消失 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`scripts/jyotish_engine.py` pl9 长报告、专业参考 Markdown +- 用户现象:三年 KP 月表还在,但 KP Lord/Sub 原始表和事业/财运/关系章的 KP 结构小表整段不见,页面上也没有 blocked 说明。 +- 触发条件:专业参考路径把 `cmd_kp` 收成 `{status: blocked, reason}` 且没有 planets/houses;渲染层遇到该信封直接返回空列表。鉴别条件是候选窗/blocked KP 信封,不是 Raman 本身。 +- 根因:`_kp_lord_sub_section` 等装配函数在 KP 模块失败时 `return []`,计划段被静默跳过。 +- 修复:失败路径改为输出显式 blocked 标题和原因码;最终 Markdown 再扫一遍计划标题,缺席则补 `planned_section_unavailable`。 +- 验证:`tests/test_report_longform_gaps2.py`。 +- 防复发:计划内段必须 present 或 blocked,不允许缺席。禁止再对失败 KP 信封直接 `return []`。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待本单合入 SHA + +## BUG-562 | 专业参考路径剥掉 raw_full_reading 后模块审计表只剩表头 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`scripts/jyotish_engine.py` 长报告审计表、力量章 Yogini/Narayana/Kala/Bhava Bala +- 用户现象:逐模块执行审计只有表头、零数据行。力量章缺 Bhava Bala 与三套辅助大运明细。 +- 触发条件:professional 路径 sanitize 掉 `raw_full_reading`;worksheet 只抄了 dasha 家族状态、没有 periods。 +- 根因:审计行只从将被剥离的 raw 模块读;辅助大运渲染拿不到规范化 periods。 +- 修复:sanitize 前抽出 `_iter_audit_modules`;规范化 dasha family periods,并从本地 yogini/narayana/kala/bhava_bala 模块补装,缺数据走 blocked 行。 +- 验证:`tests/test_report_longform_gaps2.py`。 +- 防复发:full pack 下审计表数据行必须达到模块执行数下限;力量章六子块缺数据必须有 blocked 标题。 +- 相关记录:BUG-561 +- 复发自:无 +- 修复版本:待本单合入 SHA + diff --git a/docs/tasks/PROGRESS-report-longform-gaps2-20260906.md b/docs/tasks/PROGRESS-report-longform-gaps2-20260906.md new file mode 100644 index 00000000..9693231f --- /dev/null +++ b/docs/tasks/PROGRESS-report-longform-gaps2-20260906.md @@ -0,0 +1,33 @@ +# PROGRESS · 长报告对齐二轮 gaps2(2026-09-06) + +工作树:`.worktrees/report-md-page-20260906`(与 MD 详情页串行) +分支:`codex/report-md-page-20260906` +任务书:`TASK-report-longform-gaps2-20260906.md` + +## 任务 0 根因 + +专业参考路径可以把 `cmd_kp` 收成 `{status: blocked, reason: ...}` 且没有 `planets` / `houses`。渲染层 `_kp_lord_sub_section` 与专题 KP 表遇到该信封曾直接 `return []`,计划段无声消失。三年 KP 月表不走同一信封,所以仍在。 + +触发变量是 **API 候选窗 / blocked KP 信封**,不是 Raman 本身。同构虚构盘(候选窗 + raman + full pack)在单元层用 blocked KP 信封复现;本轮未把真实用户盘入库。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 0 根因 | 完成 | 见上;BUG-561 | +| 1 KP + 静默跳过禁令 | 完成 | blocked 标题;最终 heading-ensure | +| 2 审计表零行 | 完成 | `_iter_audit_modules` 在 raw 剥离前抽出;BUG-562 | +| 3 力量章六子块 | 完成 | Bhava Bala 规范化;Yogini/Narayana/Kala periods 从本地模块补装 | +| 4 年度回放按年展开 | 完成 | `_annual_series_surface` 保留 replay 键;缺年 blocked | +| 4b 时间精度合同 | 完成 | 四粒度:day blocked;week/month parameter_sensitive;year 有年度系列则 partial_verified | +| 4c 出生 provenance | 完成 | `birthplace_label` / `coordinate_source=user_reported` / `time_source` / `uncertainty_minutes`;坐标来源保持 user_reported | +| 5 C 组小段 | 完成(blocked-or-present) | 原始模块索引、Technique Audit、Vargas I 等走显式 heading;分钟排行等与全量 Agent 行数未做真实盘逐行校准 | +| 6 质量门 | 完成 | `tests/test_report_longform_gaps2.py` 与 timing/parity 相关切片 | + +## 质量门 + +```bash +.venv/bin/python -m pytest tests/test_report_longform_gaps2.py tests/test_timing_precision_contract.py tests/test_report_longform_parity.py -q +``` + +未改 `.gitea/workflows/**`,不提升 main。 diff --git a/docs/tasks/PROGRESS-report-md-page-20260906.md b/docs/tasks/PROGRESS-report-md-page-20260906.md new file mode 100644 index 00000000..a636cd77 --- /dev/null +++ b/docs/tasks/PROGRESS-report-md-page-20260906.md @@ -0,0 +1,50 @@ +# PROGRESS · 报告详情页改渲染长报告 MD(2026-09-06) + +工作树:`.worktrees/report-md-page-20260906` +分支:`codex/report-md-page-20260906` +基线:`origin/staging` `e428e9d0` +任务书:`TASK-report-md-page-20260906.md`(硬依赖 gaps2,已在同一 worktree 完成) +未改 `.gitea/workflows/**`,不提升 main。 + +## 开工回执 + +- 目标:新报告 worker 只生成 pl9 长报告 MD;详情页渲染该 MD;writer 停用保留;零模型用量必须结算。 +- 顺序:gaps2 装配缺口 → MD-only 生成/计费 → 详情页 TOC/懒渲染 → 导出与报告中心 → 质量门。 +- 最大风险:`complete_personal_report_job` 仍要合法 v2 封面文档;计费 `completeUsage` 必须接受 0 token,且不得把 `actualModelId` 写成 `unknown`。 + +## 我替领导拍的板 + +- 计费:沿用 `report.full` 预留,结算 `inputTokens=0 outputTokens=0 costMicrousd=0`,`actualModelId` 用创建时的目录默认模型 id。缺少合法模型 id 时 **在报告标 ready 之前** 重试,避免预留悬挂。 +- 封面文档:writer 不再跑;写入一份最小合法 v2 文档(各主题走 blocked disclosure),页面不渲染它。卡片摘要来自生成时对 MD「摘要」的截取,仍落在封面 `executiveSummary.summary`,列表 JSON 路径不变。 +- 旧报告:无附录 MD 即显示「旧版本报告,请重新生成」,不维护五章视图。professional-reference POST 改为只读缓存,缺失返回 410。 +- 导出:详情页用内存中的同一份 MD 下载;文件名 `个人报告-YYYY-MM-DD`。打印为次按钮。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| gaps2 | 完成 | 见 `PROGRESS-report-longform-gaps2-20260906.md`;BUG-561 / BUG-562 | +| MD-only 生成 + 计费 | 完成 | `PERSONAL_REPORT_WRITER_ENABLED=false`;0-token settle | +| 详情页 TOC/懒渲染 | 完成 | `react-markdown` + `remark-gfm` + `skipHtml`;首屏导航+摘要;H2 懒渲染 | +| 导出 / 报告中心 | 完成 | 「导出报告(.md)」;一步式等待文案 | +| 端到端 staging | 待部署后 | 本机质量门先跑;staging 真实创建仍需部署 | + +## 计费适配 + +`complete_usage` 已允许 `input_tokens >= 0`。生产 worker 在长报告路径上: + +1. 先加载模型目录,没有默认模型则 `model_unavailable`(可重试),不跑引擎。 +2. 生成成功后带 `actualModelId=catalog.defaultModelId`、0 token。 +3. worker-core 若看到空/`unknown` 模型 id,在 `completeReportReady` 之前抛错,避免报告已 ready、预留永不结算。 + +## 首屏渲染 + +本地对含导航/摘要/宽表/诚实标签的样例 Markdown 做 `renderToStaticMarkup`,`frontend/tests/personal-report-longform-md.test.ts` 锁首屏 < 1500ms(本机实测约 30ms)。完整 ~250KB / 2700 行报告的浏览器首屏仍需 staging 部署后补测;H2 分段懒渲染后首屏只挂载导航+摘要。 + +## 质量门命令 + +```bash +.venv/bin/python -m pytest tests/test_report_longform_gaps2.py tests/test_timing_precision_contract.py tests/test_report_longform_parity.py -q +cd frontend && ./node_modules/.bin/tsc --noEmit +cd frontend && ./node_modules/.bin/tsx --test tests/personal-report-longform-md.test.ts tests/personal-report-view.test.ts tests/personal-report-export.test.ts tests/personal-report-entry.test.ts tests/report-polling-contract.test.ts tests/professional-report-reference-route.test.ts tests/personal-report-worker.test.ts tests/personal-report-api.test.ts +``` diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 2abb2560..b10abddc 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -348,20 +348,20 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block. - **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`. - **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px. -- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “全量数据附录” action beside it. The appendix action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records. Appendix generation never changes the stored five-chapter report status. -- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow. +- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and “导出报告(.md)” beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time. +- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. - **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating. ### Personal report reader -- **Structure:** sticky screen chrome (back, print, full-data appendix), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance. -- **Surface:** page floor `--color-canvas-soft`; the document is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome, and pins the light palette. -- **Typography:** the subject name uses `--type-display-lg` serif at weight 400. Section titles use `--type-display-sm` serif at weight 400. Theme headings are `--type-title-md` sans at weight 500. Narrative is `--type-body-md` at 1.65, the same measure as chat answers. Labels stay 12–13px. -- **Accent:** claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge. -- **Charts:** North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex. -- **Width:** 900px for the document; chart and evidence columns stack at 760px. -- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses `InlineSpinner`. -- **Accessibility:** back, print, appendix download, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. +- **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”. +- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette. +- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed. +- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge. +- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. +- **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px. +- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy. +- **Accessibility:** back, print, and Markdown export are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. ### Product entrypoint card diff --git a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts index f7085d09..234cdd83 100644 --- a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts +++ b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts @@ -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 | 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 }); } } diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index 3a2e2d15..ae950b1c 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -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 diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 8bdc00e0..6c053173 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -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 } : {}), }; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 70865e22..5df67a41 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 35d0eece..6d764b31 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -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() { {copy.label}

{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}

-

{copy.description}

+

{report.status === "ready" && report.cardSummary ? report.cardSummary : copy.description}

{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"} {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 ? : null} - 全量数据附录 + {PERSONAL_REPORT_EXPORT_LABEL} {exportError?.reportId === report.id ? ( diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx new file mode 100644 index 00000000..ae9bc87f --- /dev/null +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -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 {children}; + } + return {children}; + }, + img: () => null, + table: ({ children }) => ( +
+ {children}
+
+ ), + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + }; +} + +function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) { + return ( + + {markdown} + + ); +} + +function LazyMarkdownSection({ + section, +}: { + section: LongformSection; +}) { + const ref = useRef(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 ( +
+ {visible ? renderMarkdown(section.markdown, section.headings) : ( +

{section.title}

+ )} +
+ ); +} + +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 ( +
+ +
+ {outline.leadMarkdown ? ( +
+ {renderMarkdown(outline.leadMarkdown, outline.headings)} +
+ ) : null} + {outline.sections.filter((section) => !section.eager).map((section) => ( + + ))} +
+
+ ); +} + +function TocList({ + items, + activeId, +}: { + items: readonly LongformHeading[]; + activeId: string; +}) { + return ( +
    + {items.map((item) => ( +
  1. + + {item.title} + +
  2. + ))} +
+ ); +} diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 9d25bde6..bc08019a 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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; 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 }) {

- {generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"} + {generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}

{generating && ( <> @@ -321,13 +316,43 @@ export function PersonalReportPage({ reportId }: { reportId: string }) { ); } - const document = state.document; + if (state.phase === "legacy-unavailable") { + return ( +
+
+ ); + } + + if (state.phase !== "markdown-ready") { + return ( +
+
+ ); + } + return (
- +
- +
); diff --git a/frontend/src/components/personal-report/report-actions.tsx b/frontend/src/components/personal-report/report-actions.tsx index 70dc10b4..8d7750b0 100644 --- a/frontend/src/components/personal-report/report-actions.tsx +++ b/frontend/src/components/personal-report/report-actions.tsx @@ -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(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) {
{notice && } - -
); -} \ No newline at end of file +} diff --git a/frontend/src/lib/personal-report-longform-birth.ts b/frontend/src/lib/personal-report-longform-birth.ts new file mode 100644 index 00000000..62c5fd6a --- /dev/null +++ b/frontend/src/lib/personal-report-longform-birth.ts @@ -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)[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; + 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 | 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), + }; +} diff --git a/frontend/src/lib/personal-report-longform-copy.ts b/frontend/src/lib/personal-report-longform-copy.ts new file mode 100644 index 00000000..d301d50b --- /dev/null +++ b/frontend/src/lib/personal-report-longform-copy.ts @@ -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 = "打印"; diff --git a/frontend/src/lib/personal-report-longform-cover.ts b/frontend/src/lib/personal-report-longform-cover.ts new file mode 100644 index 00000000..38ca3e1a --- /dev/null +++ b/frontend/src/lib/personal-report-longform-cover.ts @@ -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; +} diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts index 56b35580..097e5563 100644 --- a/frontend/src/lib/personal-report-longform-download.ts +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -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 { 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 { - const markdown = await requestPersonalReportLongformAppendix(reportId); - downloadMarkdownReport("个人全量数据附录", markdown); +export async function downloadPersonalReportLongformAppendix( + reportId: string, + options: Readonly<{ markdown?: string; reportDate?: string | null }> = {}, +): Promise { + const markdown = options.markdown?.trim() + ? options.markdown + : await requestPersonalReportLongformAppendix(reportId); + downloadMarkdownReport(personalReportMarkdownFilename(options.reportDate), markdown); } diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts new file mode 100644 index 00000000..c201fa78 --- /dev/null +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -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; + +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 { + 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 { + 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; + apiBase: string; + fetchImpl: typeof fetch; + signal?: AbortSignal; +}>): Promise { + 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 { + 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"); + } +} diff --git a/frontend/src/lib/personal-report-longform-outline.ts b/frontend/src/lib/personal-report-longform-outline.ts new file mode 100644 index 00000000..73d793f0 --- /dev/null +++ b/frontend/src/lib/personal-report-longform-outline.ts @@ -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 { + 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(); + 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, "-"); +} diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 5d3d5306..95e9c38d 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -651,6 +651,10 @@ export type ReportReadCoreDeps = Readonly<{ status: string; lastErrorCode: string | null; }>[]>; + loadLongformMarkdown?: (input: Readonly<{ + userId: string; + reportId: string; + }>) => Promise; }>; export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { @@ -680,10 +684,32 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise | 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); }, }); } diff --git a/frontend/src/lib/personal-report-writer-flag.ts b/frontend/src/lib/personal-report-writer-flag.ts new file mode 100644 index 00000000..a7f35a12 --- /dev/null +++ b/frontend/src/lib/personal-report-writer-flag.ts @@ -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; diff --git a/frontend/src/lib/server-owned-birth-profile.ts b/frontend/src/lib/server-owned-birth-profile.ts index 363f5904..d8913d5d 100644 --- a/frontend/src/lib/server-owned-birth-profile.ts +++ b/frontend/src/lib/server-owned-birth-profile.ts @@ -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; diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 032af545..6f8b8404 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -1132,6 +1132,38 @@ test("core read: returns a legitimate ready document after canonical re-validati assert.ok(response.body.reportDocument); }); +test("core read: markdown loader returns longform and hides the five-chapter body when it is missing", async () => { + const persistence = new MemoryPersistence(); + persistence.rows.set(REPORT_ID, seedRecord()); + const withMarkdown = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + loadLongformMarkdown: async () => "# 长报告\n\n### 摘要\n正文", + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(withMarkdown.status, 200); + assert.equal(withMarkdown.body.longformMarkdown, "# 长报告\n\n### 摘要\n正文"); + assert.ok(withMarkdown.body.reportDocument); + + const missing = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + loadLongformMarkdown: async () => null, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(missing.status, 200); + assert.equal(missing.body.longformMarkdown, null); + assert.equal("reportDocument" in missing.body, false); +}); + test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => { const persistence = new MemoryPersistence(); await createReadyRow(persistence); @@ -1232,6 +1264,7 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/); assert.match(itemRoute, /resolveReportRead/); assert.match(itemRoute, /resolveReportDelete/); + assert.match(itemRoute, /loadLongformMarkdown/); assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/); }); @@ -1277,7 +1310,12 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi createRoute.indexOf("const REPORT_LIST_COLUMNS"), createRoute.indexOf("function sanitizedErrorCode"), ); - assert.doesNotMatch(listColumns, /report_document|calculation_hash|evidence_hash/); + assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/); + assert.match(listColumns, /card_summary:report_document->executiveSummary->>summary/); + assert.doesNotMatch( + listColumns.replace("card_summary:report_document->executiveSummary->>summary", ""), + /report_document/, + ); assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/); assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/); assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/); diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index b57cf53f..6e6f029f 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -285,11 +285,14 @@ test("legacy consultation Markdown export is untouched and still works", () => { }); -test("ready reports expose the full-data appendix Markdown export only in the ready branch", () => { - assert.match(reportCenterSource, /全量数据附录/); +test("ready reports expose Markdown export only in the ready branch", () => { + assert.match(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL/); assert.match(reportCenterSource, /downloadPersonalReportLongformAppendix/); + assert.match(reportCenterSource, /cardSummary/); assert.match(longformDownloadSource, /professional-reference/); - assert.match(longformDownloadSource, /downloadMarkdownReport\("个人全量数据附录", markdown\)/); + assert.match(longformDownloadSource, /personalReportMarkdownFilename/); + assert.doesNotMatch(reportCenterSource, /全量数据附录/); + assert.doesNotMatch(reportCenterSource, /章节/); const readyActionStart = reportCenterSource.indexOf( '{report.status === "ready" ? (', ); @@ -299,6 +302,6 @@ test("ready reports expose the full-data appendix Markdown export only in the re ); assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart); const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart); - assert.match(readyBranch, /全量数据附录/); - assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /全量数据附录/); + assert.match(readyBranch, /PERSONAL_REPORT_EXPORT_LABEL/); + assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /PERSONAL_REPORT_EXPORT_LABEL/); }); diff --git a/frontend/tests/personal-report-export.test.ts b/frontend/tests/personal-report-export.test.ts index a104aabc..20c9ed47 100644 --- a/frontend/tests/personal-report-export.test.ts +++ b/frontend/tests/personal-report-export.test.ts @@ -89,12 +89,10 @@ test("client export never touches a server PDF pipeline", () => { }); -test("native print uses the same validated structured DOM for stored v1 and current v2", () => { - assert.match(pageSource, /safeParseReportDocument\(json\.reportDocument\)/); - assert.match(pageSource, //); - assert.match(documentViewSource, /isReportDocumentV2\(document\)/); - assert.match(documentViewSource, / { + assert.match(pageSource, /longformMarkdown/); + assert.match(pageSource, /PersonalReportMarkdownView/); + assert.doesNotMatch(pageSource, /PersonalReportDocumentView/); assert.doesNotMatch(documentViewSource, /dangerouslySetInnerHTML|srcDoc|