From b2ad772e4db22869e3ed08eb691a1acc0736a145 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 29 Sep 2026 15:03:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20English=20edition=20in=20the?= =?UTF-8?q?=20reader=20=E2=80=94=20projection,=20fact=20tables,=20charts,?= =?UTF-8?q?=20export,=20snapshot=20v2,=20route,=20language=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- .../src/app/(app)/reports/[reportId]/page.tsx | 6 +- .../src/app/api/reports/[reportId]/route.ts | 15 +- frontend/src/app/globals.css | 11 + .../personal-report-markdown-view.tsx | 39 ++-- .../personal-report/personal-report-page.tsx | 67 +++++- .../personal-report/report-actions.tsx | 41 +++- .../report-calculation-charts.tsx | 23 ++- .../personal-report/report-export-drawer.tsx | 22 +- .../personal-report/report-fact-tables.tsx | 20 +- .../personal-report/vedic-chart-svg.tsx | 17 +- .../lib/personal-report-english-edition.ts | 36 ++++ .../lib/personal-report-longform-generate.ts | 12 +- .../lib/personal-report-longform-outline.ts | 11 +- .../lib/personal-report-longform-snapshot.ts | 33 ++- .../src/lib/personal-report-route-core.ts | 25 +++ frontend/src/lib/report-chart-block.ts | 14 +- frontend/src/lib/report-chart-export.tsx | 7 +- frontend/src/lib/report-export-blocks.ts | 64 +++--- frontend/src/lib/report-fact-table-display.ts | 17 +- frontend/src/lib/report-fact-table-values.ts | 17 ++ frontend/src/lib/report-fact-tables.ts | 123 ++++++++--- frontend/src/lib/report-language.ts | 31 +++ frontend/src/lib/report-public-projection.ts | 45 ++++- frontend/tests/personal-report-view.test.ts | 5 +- .../tests/report-english-edition.test.tsx | 191 ++++++++++++++++++ 25 files changed, 750 insertions(+), 142 deletions(-) create mode 100644 frontend/src/lib/personal-report-english-edition.ts create mode 100644 frontend/src/lib/report-language.ts create mode 100644 frontend/tests/report-english-edition.test.tsx diff --git a/frontend/src/app/(app)/reports/[reportId]/page.tsx b/frontend/src/app/(app)/reports/[reportId]/page.tsx index c99c91b5..5b6201d5 100644 --- a/frontend/src/app/(app)/reports/[reportId]/page.tsx +++ b/frontend/src/app/(app)/reports/[reportId]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { PersonalReportPage } from "@/components/personal-report/personal-report-page"; +import { readReportLanguage } from "@/lib/report-language"; export const dynamic = "force-dynamic"; @@ -11,9 +12,12 @@ export const metadata: Metadata = { export default async function ReportPage({ params, + searchParams, }: { params: Promise<{ reportId: string }>; + searchParams: Promise<{ lang?: string | string[] }>; }) { const { reportId } = await params; - return ; + const { lang } = await searchParams; + return ; } diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index 4f0ef858..8fcda635 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -6,6 +6,8 @@ import { resolveReportRead, } from "@/lib/personal-report-route-core"; import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server"; +import { readLongformSnapshot, type LongformSnapshotReader } from "@/lib/personal-report-longform-snapshot"; +import type { EnglishEditionRead } from "@/lib/personal-report-route-core"; import { LONGFORM_APPENDIX_TABLE, parseLongformAppendixRow, @@ -49,6 +51,7 @@ async function resolvePersistenceForUser() { jobs: null, listSections: undefined, loadLongformAppendix: undefined, + loadLongformEnglish: undefined, }; } const persistence = createSupabasePersonalReportService(supabase); @@ -84,12 +87,21 @@ async function resolvePersistenceForUser() { markdown: row.markdown, }; }, + loadLongformEnglish: async (input: Readonly<{ userId: string; reportId: string; requestId: string }>): Promise => { + const stored = await readLongformSnapshot(supabase as unknown as LongformSnapshotReader, input); + if (stored?.status !== "ready") return { status: "legacy" }; + const snapshot = stored.snapshot; + if (snapshot.version === 1) return { status: "legacy" }; + return snapshot.english + ? { status: "ready", markdown: snapshot.english.markdown, factTables: snapshot.english.factTables } + : { status: "unavailable" }; + }, }; } export async function GET(request: Request, context: RouteContext) { try { - const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser(); + const { userId, persistence, jobs, listSections, loadLongformAppendix, loadLongformEnglish } = await resolvePersistenceForUser(); const { reportId } = await context.params; if (!uuidPattern.test(reportId)) { return NextResponse.json( @@ -116,6 +128,7 @@ export async function GET(request: Request, context: RouteContext) { jobs: jobs ?? undefined, listSections, loadLongformAppendix, + loadLongformEnglish, validateReadyDocument: (document) => { const parsed = safeParseServerReportDocument(document); return parsed.ok diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 49384c66..e71ea758 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4004,6 +4004,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-heading h2 { margin: 0; font-size: var(--type-title-lg); } .report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); } .report-export-count { font-variant-numeric: tabular-nums; } +/* 「问 AI 建议导出英文版」: one quiet band inside the header, with the switch beside it. */ +.report-export-ai-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: var(--space-2) 0; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.report-export-ai-hint p { margin: 0 !important; color: var(--color-ink) !important; } +.report-export-ai-hint > button { flex-shrink: 0; } +.report-export-edition-note { color: var(--color-ink-tertiary) !important; font-size: var(--type-caption) !important; } +@media (max-width: 767px) { .report-export-ai-hint { flex-direction: column; align-items: stretch; } } .report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); } /* Selected preset: neutral raised fill + full ink + a visible edge. The old --color-action-soft fill reads as a dark red-brown block in the dark theme @@ -4250,6 +4256,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .personal-report-back:hover { color: var(--color-ink); background: var(--color-canvas-muted); } .personal-report-action-end { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); } +/* 中文 | English: a two-segment switch in the reader's action bar, only on reports with both editions. */ +.report-language-switch { display: inline-flex; padding: 2px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas); } +.report-language-switch button { min-height: 40px; min-width: 64px; padding: 0 var(--space-3); border: 0; border-radius: 999px; background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; } +.report-language-switch button[aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); font-weight: 600; } +.report-language-switch button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } .personal-report-action-end > span { max-width: 440px; display: inline-flex; diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index 59c5cdff..5fc09c17 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -14,6 +14,7 @@ import { import { reportChartGrid } from "@/lib/report-chart-grid-rehype"; import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg"; import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump"; +import type { ReportLanguage } from "@/lib/report-language"; import { buildLongformOutline, type LongformHeading, @@ -34,7 +35,7 @@ function reportUrlTransform(url: string): string { return url.startsWith("#") ? url : ""; } -function markdownComponents(headings: readonly LongformHeading[]): Components { +function markdownComponents(headings: readonly LongformHeading[], language: ReportLanguage): Components { const cursor = { h2: 0, h3: 0 }; const nextId = (level: 2 | 3, children: ReactNode) => { const title = flattenText(children).trim(); @@ -74,13 +75,14 @@ function markdownComponents(headings: readonly LongformHeading[]): Components { } const block = parseReportChartBlock(fencedSource(children).trim()); if (!block) { - return

图盘数据无效

; + return

{language === "en" ? "Chart data is invalid" : "图盘数据无效"}

; } return (
); @@ -88,10 +90,10 @@ function markdownComponents(headings: readonly LongformHeading[]): Components { }; } -function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) { +function renderMarkdown(markdown: string, headings: readonly LongformHeading[], language: ReportLanguage) { return ( (null); const [seen, setVisible] = useState(section.eager); @@ -144,10 +148,10 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({ return () => observer.disconnect(); }, [visible]); const content = useMemo( - () => (visible ? renderMarkdown(section.markdown, section.headings) : ( + () => (visible ? renderMarkdown(section.markdown, section.headings, language) : (

{section.title}

)), - [visible, section], + [visible, section, language], ); return (
@@ -156,7 +160,8 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({ ); }); -function ReportToc({ headings }: { headings: readonly LongformHeading[] }) { +function ReportToc({ headings, language }: { headings: readonly LongformHeading[]; language: ReportLanguage }) { + const label = language === "en" ? "Contents" : "目录"; const [activeId, setActiveId] = useState(headings[0]?.id ?? ""); useEffect(() => { @@ -176,20 +181,20 @@ function ReportToc({ headings }: { headings: readonly LongformHeading[] }) { }, [headings]); return ( - diff --git a/frontend/src/components/personal-report/report-calculation-charts.tsx b/frontend/src/components/personal-report/report-calculation-charts.tsx index 47211a3d..216f2ba1 100644 --- a/frontend/src/components/personal-report/report-calculation-charts.tsx +++ b/frontend/src/components/personal-report/report-calculation-charts.tsx @@ -3,17 +3,28 @@ import { memo } from "react"; import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract"; import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg"; +import type { ReportLanguage } from "@/lib/report-language"; -export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts }: { charts: ReportDocumentV2["charts"] }) { +const COPY = { + zh: { heading: "分盘核对", note: "原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。" }, + en: { heading: "Divisional charts for checking", note: "Raw calculations for checking. Divisional charts are sensitive to birth time and do not by themselves make a conclusion more certain." }, +} as const; + +export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, language = "zh" }: { charts: ReportDocumentV2["charts"]; language?: ReportLanguage }) { + const copy = COPY[language]; const actual = charts.filter(chart => hasRealChartData(chart as ChartV1)); if (!actual.length) return null; - return
-

分盘核对

-

原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。

+ return
+

{copy.heading}

+

{copy.note}

{actual.map(chart =>
-

{chart.title}

- +

{language === "en" ? `${chart.id} chart` : chart.title}

+
)}
; diff --git a/frontend/src/components/personal-report/report-export-drawer.tsx b/frontend/src/components/personal-report/report-export-drawer.tsx index f5839d08..b33f4d3d 100644 --- a/frontend/src/components/personal-report/report-export-drawer.tsx +++ b/frontend/src/components/personal-report/report-export-drawer.tsx @@ -6,14 +6,19 @@ import { detectPrintRestriction } from "@/lib/client-report-export"; import { downloadMarkdownReport } from "@/lib/consultation-report-export"; import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks"; import { Button } from "@/components/ui/button"; +import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language"; const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const; const GROUPS = { body: "正文", chart: "分盘", table: "核对表" } as const; -export function ReportExportDrawer({ blocks, reportDate, onClose }: { +export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh", englishEdition = "legacy", onSwitchToEnglish }: { blocks: readonly ReportExportBlock[]; reportDate?: string | null; onClose: () => void; + /** Edition the blocks are in; the file is exported in it. */ + language?: ReportLanguage; + englishEdition?: EnglishEditionStatus; + onSwitchToEnglish?: () => void; }) { const dialog = useRef(null); const [selection, setSelection] = useState(() => presetSelection("common", blocks)); @@ -53,7 +58,7 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: { try { const body = blocks.filter(block => block.group === "body"); const full = body.length > 0 && body.every(block => selection.has(block.id)); - downloadMarkdownReport(exportFilename(reportDate, full), joinExportBlocks(selected)); + downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected)); dialog.current?.close(); onClose(); toast.success(`已导出 ${selected.length} 块`); @@ -69,7 +74,18 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {

分块导出

-

勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。

+

勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。{language === "en" ? "导出的是英文版。" : ""}

+ {language === "zh" && englishEdition === "ready" && onSwitchToEnglish ? ( +
+

要拿去问 ChatGPT、Claude 等 AI?建议切到 English 再导出,术语与原典一致,AI 读得更准。

+ +
+ ) : null} + {englishEdition === "legacy" ? ( +

这份报告生成于英文版上线前,重新生成即可获得中英两版。

+ ) : englishEdition === "unavailable" ? ( +

这份报告的英文版没有生成成功,只能导出中文版。

+ ) : null}

共 {blocks.length} 块 · {chars(blocks)} 字 · {totalSize} · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字 · {selected.length ? selectedSize : "0 KB"}

{(Object.keys(PRESETS) as ReportExportPreset[]).map(key => )} diff --git a/frontend/src/components/personal-report/report-fact-tables.tsx b/frontend/src/components/personal-report/report-fact-tables.tsx index 5685a61e..c21b637c 100644 --- a/frontend/src/components/personal-report/report-fact-tables.tsx +++ b/frontend/src/components/personal-report/report-fact-tables.tsx @@ -4,9 +4,10 @@ import { memo, useEffect, useRef } from "react"; import type { ReportFactTable } from "@/lib/report-fact-table-schema"; import { bindFactTablePrint } from "@/lib/report-fact-table-print"; -import { factCellText, FACT_STATUS_LABELS } from "@/lib/report-fact-table-display"; +import { factCellText, factStatusLabel } from "@/lib/report-fact-table-display"; +import type { ReportLanguage } from "@/lib/report-language"; -function FactTable({ table }: { table: Pick }) { +function FactTable({ table, language }: { table: Pick; language: ReportLanguage }) { if (!table.rows.length) return null; return (
@@ -15,7 +16,7 @@ function FactTable({ table }: { table: Pick{table.columns.map((column) => {column})} {table.rows.map((row) => ( {row.cells.map((cell, index) => ( - {factCellText(cell)} + {factCellText(cell, language)} ))} ))} @@ -24,25 +25,26 @@ function FactTable({ table }: { table: Pick(null); useEffect(() => { if (root.current) return bindFactTablePrint(root.current, window); }, []); return ( -
-

计算数据核对表

+
+

{heading}

{tables.map((table) => (
- {table.title} · {FACT_STATUS_LABELS[table.claimStatus]} + {table.title} · {factStatusLabel(table.claimStatus, language)}

{table.note}

- + {table.subtables?.map((subtable) => (

{subtable.title}

{subtable.note &&

{subtable.note}

} - +
))}
diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index e307d423..78ee9d81 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -35,6 +35,7 @@ import { PLANET_NAME_ZH, PLANET_COLOR_CLASS, PLANET_GLYPH, + PLANET_ABBR, PLANET_GLYPH_ZH, PLANET_LEGEND_ORDER, parseChartOccupant, @@ -42,6 +43,7 @@ import { type PlanetGlyphKey, } from "@/lib/planet-glyphs"; import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block"; +import type { ReportLanguage } from "@/lib/report-language"; export const CHART_VIEWBOX_WIDTH = CHART_SIZE; export const CHART_VIEWBOX_HEIGHT = CHART_SIZE; @@ -56,7 +58,7 @@ const LEGEND_LABEL_OFFSET = 17; // 400-unit line at legend size without crowding. const LEGEND_ROW_ONE = PLANET_LEGEND_ORDER.slice(0, 5); const LEGEND_ROW_TWO = PLANET_LEGEND_ORDER.slice(5); -const LEGEND_RETROGRADE_COPY = "横线=逆行"; +const LEGEND_RETROGRADE_COPY: Record = { zh: "横线=逆行", en: "bar = retrograde" }; /** Symbol grid inside one house, in viewBox units of the 400-unit square. */ const GLYPHS_PER_ROW = 3; @@ -84,6 +86,8 @@ interface VedicChartSvgProps { * so its lagna mark sits wherever the natal ascendant falls from there. */ ascendantHouse?: number; + /** Legend language; the glyphs themselves are the same in both report editions. */ + language?: ReportLanguage; } type ChartToken = @@ -273,7 +277,8 @@ function OccupantGrid({ tokens, origin, interactive }: { tokens: ChartToken[]; o } /** Nine symbols with their one-character names, plus what the bar under one means. */ -function ChartLegend() { +function ChartLegend({ language }: { language: ReportLanguage }) { + const names = language === "en" ? PLANET_ABBR : PLANET_GLYPH_ZH; const row = (keys: readonly PlanetGlyphKey[], y: number) => keys.map((key, index) => ( - {PLANET_GLYPH_ZH[key]} + {names[key]} )); @@ -301,7 +306,7 @@ function ChartLegend() { y={LEGEND_ROW_TWO_Y} className="personal-report-chart-legend" > - {LEGEND_RETROGRADE_COPY} + {LEGEND_RETROGRADE_COPY[language]} ); @@ -313,7 +318,7 @@ export function VedicChartSvg(props: VedicChartSvgProps) { } /** Shared, hook-free drawing for the reader and offline Markdown export. */ -export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1, interactive: enableHelp = false }: VedicChartSvgProps & { chartId: string }) { +export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1, interactive: enableHelp = false, language = "zh" }: VedicChartSvgProps & { chartId: string }) { const interactive = enableHelp && !skeleton; const chart = skeleton ? EMPTY_NORTH_INDIAN_CHART : suppliedChart; const retrograde = retrogradeKeys(chart); @@ -387,7 +392,7 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh ); })} - {hasPlanets && } + {hasPlanets && } ); } diff --git a/frontend/src/lib/personal-report-english-edition.ts b/frontend/src/lib/personal-report-english-edition.ts new file mode 100644 index 00000000..8bcb0433 --- /dev/null +++ b/frontend/src/lib/personal-report-english-edition.ts @@ -0,0 +1,36 @@ +import { hashLongformMarkdown } from "./personal-report-longform-appendix"; +import type { LongformEnglishEdition } from "./personal-report-longform-snapshot"; +import { assembleReportFactTables } from "./report-fact-tables"; +import type { ReportFactTable } from "./report-fact-table-schema"; +import { containsHan } from "./report-language"; +import { ordinaryOutputLeaks } from "./report-public-projection"; + +export type EnglishOutcome = { english: LongformEnglishEdition; englishUnavailable?: undefined } + | { english?: undefined; englishUnavailable: string }; + +/** + * The English edition is subordinate: anything wrong with it drops English and + * records why, and the Chinese report goes on to ready regardless. It must hold + * no Han character (red line 2) and carry none of the audit vocabulary the + * public projection removes (red line 4) — an edition the projection would have + * to cut is not shipped half-cut. + */ +export function acceptEnglishEdition(markdown: unknown, unavailable: unknown, factPacket: unknown): EnglishOutcome { + if (typeof markdown !== "string" || !markdown.trim()) { + const code = typeof unavailable === "string" && /^[a-z_]{1,64}$/.test(unavailable) ? unavailable : "engine_missing"; + return { englishUnavailable: code }; + } + if (containsHan(markdown)) return { englishUnavailable: "han_leak" }; + // Checked on the engine text itself: the projection would silently cut a leak, + // and the reader would get a quietly shortened edition. + if (ordinaryOutputLeaks(markdown, "en").length > 0) return { englishUnavailable: "public_leak" }; + let factTables: ReportFactTable[]; + try { + factTables = assembleReportFactTables(factPacket, "en"); + } catch { + return { englishUnavailable: "fact_tables_invalid" }; + } + if (containsHan(JSON.stringify(factTables))) return { englishUnavailable: "fact_tables_han" }; + return { english: { markdown, contentSha256: hashLongformMarkdown(markdown), factTables } }; +} + diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts index a59429be..519d7105 100644 --- a/frontend/src/lib/personal-report-longform-generate.ts +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -3,6 +3,7 @@ import "server-only"; import { assembleReportFactTables } from "./report-fact-tables"; import type { ReportFactTable } from "./report-fact-table-schema"; import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot, type LongformSnapshot } from "./personal-report-longform-snapshot"; +import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-english-edition"; import { hashLongformMarkdown } from "./personal-report-longform-appendix"; import { @@ -138,7 +139,7 @@ async function fetchLongformMarkdown(input: Readonly<{ apiBase: string; fetchImpl: typeof fetch; signal?: AbortSignal; -}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string }> { +}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string } & EnglishOutcome> { const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS); const signal = input.signal ? AbortSignal.any([input.signal, timeout]) @@ -149,7 +150,7 @@ async function fetchLongformMarkdown(input: Readonly<{ 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, include_fact_tables: true, edition: "reader_main" }), + body: JSON.stringify({ ...input.payload, include_fact_tables: true, edition: "reader_main", languages: ["zh", "en"] }), cache: "no-store", signal, }); @@ -161,13 +162,14 @@ async function fetchLongformMarkdown(input: Readonly<{ upstream.status === 429 ? "upstream_busy" : "upstream_unavailable", ); } - const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null; + const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; markdown_en?: unknown; english_unavailable?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null; if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) { throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown"); } return { markdown: result.markdown, factTables: assembleReportFactTables(result.fact_table_packet), charts: assembleLongformCharts(result.fact_table_packet, "ev-cover-longform"), - readerDashaApplicability: typeof result.reader_dasha_applicability === "string" ? result.reader_dasha_applicability : "" }; + readerDashaApplicability: typeof result.reader_dasha_applicability === "string" ? result.reader_dasha_applicability : "", + ...acceptEnglishEdition(result.markdown_en, result.english_unavailable, result.fact_table_packet) }; } catch (error) { throw error; } finally { @@ -230,7 +232,7 @@ export async function generatePersonalReportLongform( signal: deps.signal, }); snapshot = validateLongformSnapshot({ - ...generated, ...identity, kind: "longform_calculation_snapshot", version: 1, + ...generated, ...identity, kind: "longform_calculation_snapshot", version: 2, contentSha256: hashLongformMarkdown(generated.markdown), }, identity); // Durable complete snapshot precedes the shared Markdown cache and final report. diff --git a/frontend/src/lib/personal-report-longform-outline.ts b/frontend/src/lib/personal-report-longform-outline.ts index 0d43dcf6..909fcee1 100644 --- a/frontend/src/lib/personal-report-longform-outline.ts +++ b/frontend/src/lib/personal-report-longform-outline.ts @@ -1,3 +1,5 @@ +import type { ReportLanguage } from "./report-language"; + export type LongformHeadingLevel = 2 | 3; export type LongformHeading = Readonly<{ @@ -24,8 +26,9 @@ export type LongformOutline = Readonly<{ sections: readonly LongformSection[]; }>; -const EAGER_H2 = /成品阅读导航|质量验收矩阵/; -const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/; +// The English edition's counterparts sit beside the Chinese ones: one outline for both. +const EAGER_H2 = /成品阅读导航|质量验收矩阵|Reading guide|Quality acceptance matrix/i; +const SUMMARY_TITLE = /^(?:解读摘要|摘要|Summary|Reading summary)$/i; /** Dedicated `personal_reports.card_summary` column length. */ export const PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS = 500; @@ -186,9 +189,9 @@ export function extractLongformSummary(markdown: string, maxLength = 2000): stri return `${text.slice(0, maxLength - 1)}…`; } -export function personalReportMarkdownFilename(reportDate: string | null | undefined): string { +export function personalReportMarkdownFilename(reportDate: string | null | undefined, language: ReportLanguage = "zh"): 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, "-"); + return `${language === "en" ? "Personal-Report" : "个人报告"}-${stamp}`.replace(/[\\/:*?"<>|]+/g, "-"); } diff --git a/frontend/src/lib/personal-report-longform-snapshot.ts b/frontend/src/lib/personal-report-longform-snapshot.ts index f8c8396d..1445c7ef 100644 --- a/frontend/src/lib/personal-report-longform-snapshot.ts +++ b/frontend/src/lib/personal-report-longform-snapshot.ts @@ -8,9 +8,8 @@ import type { AppendixClient } from "./personal-report-longform-generate"; export const LONGFORM_SNAPSHOT_SECTION_ID = "longform-calculation-snapshot-v1"; export const LONGFORM_SNAPSHOT_TABLE = "personal_report_sections"; export type LongformSnapshotIdentity = { reportId: string; userId: string; requestId: string }; -const snapshotSchema = z.strictObject({ +const snapshotFields = { kind: z.literal("longform_calculation_snapshot"), - version: z.literal(1), reportId: z.string().uuid(), userId: z.string().uuid(), requestId: z.string().uuid(), markdown: z.string().min(1), contentSha256: z.string().regex(/^[a-f0-9]{64}$/), factTables: reportFactTablesSchema.refine(tables => tables.length === 8), @@ -18,7 +17,33 @@ const snapshotSchema = z.strictObject({ CHART_IDS.every(id => charts.filter(chart => chart.id === id).length === 1) && charts.every(chart => chart.houses.length === 12 && chart.houses.some(house => house.occupants.length > 0))), readerDashaApplicability: z.string().min(1), +}; + +/** + * The English edition, rendered by the engine from the same packet as the + * Chinese one (TASK-report-english-edition-20260929). Subordinate: when it is + * missing the snapshot carries `englishUnavailable` instead and the report is + * still ready in Chinese. + */ +export const longformEnglishEditionSchema = z.strictObject({ + markdown: z.string().min(1), + contentSha256: z.string().regex(/^[a-f0-9]{64}$/), + factTables: reportFactTablesSchema, }); +export type LongformEnglishEdition = z.infer; + +/** v1: Chinese only (every report before the English edition). v2: both editions, or the reason English is missing. */ +const snapshotSchema = z.union([ + z.strictObject({ ...snapshotFields, version: z.literal(1) }), + z.strictObject({ + ...snapshotFields, + version: z.literal(2), + english: longformEnglishEditionSchema.optional(), + englishUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(), + }).refine(value => (value.english === undefined) !== (value.englishUnavailable === undefined), { + message: "a v2 snapshot carries exactly one of english / englishUnavailable", + }), +]); export type LongformSnapshot = z.infer; export function validateLongformSnapshot(value: unknown, identity: LongformSnapshotIdentity): LongformSnapshot { @@ -27,6 +52,10 @@ export function validateLongformSnapshot(value: unknown, identity: LongformSnaps || snapshot.requestId !== identity.requestId || snapshot.contentSha256 !== hashLongformMarkdown(snapshot.markdown)) { throw new Error("longform_snapshot_binding_invalid"); } + if (snapshot.version === 2 && snapshot.english + && snapshot.english.contentSha256 !== hashLongformMarkdown(snapshot.english.markdown)) { + throw new Error("longform_snapshot_binding_invalid"); + } return snapshot; } diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 774ed358..aef99fb2 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -1,3 +1,5 @@ +import type { ReportFactTable } from "./report-fact-table-schema"; +import type { EnglishEditionStatus } from "./report-language"; import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range"; /** * Personal report route handlers as a dependency-injected core (no @@ -680,8 +682,21 @@ export type ReportReadCoreDeps = Readonly<{ lastErrorCode: string | null; markdown: string | null; } | null>; + /** + * The English edition from the calculation snapshot. `legacy` = the report + * predates the English edition; `unavailable` = it was attempted and dropped. + */ + loadLongformEnglish?: (input: Readonly<{ + userId: string; + reportId: string; + requestId: string; + }>) => Promise; }>; +export type EnglishEditionRead = + | Readonly<{ status: "ready"; markdown: string; factTables: readonly ReportFactTable[] }> + | Readonly<{ status: Exclude }>; + export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders); if (!originDecision.ok) { @@ -738,10 +753,20 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise ({ status: "unavailable" })) + : { status: "legacy" }; return { status: 200, body: { report: reportView(row, job), + englishEdition: english.status, + ...(english.status === "ready" ? { + longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"), + factTablesEn: english.factTables, + } : {}), longformMarkdown: projectOrdinaryReportMarkdown(markdown), ...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document ? { calculationCharts: validated.document.charts } : {}), diff --git a/frontend/src/lib/report-chart-block.ts b/frontend/src/lib/report-chart-block.ts index 82b7cdff..5e54ef72 100644 --- a/frontend/src/lib/report-chart-block.ts +++ b/frontend/src/lib/report-chart-block.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs"; +import { PLANET_ABBR, PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs"; +import type { ReportLanguage } from "./report-language"; const SIGNS = [ "Aries", @@ -138,7 +139,9 @@ export function planetDisplayLabel( name: (typeof PLANETS)[number], degree: number, retrograde: boolean, + language: ReportLanguage = "zh", ): string { + if (language === "en") return `${PLANET_ABBR[PLANET_GLYPH_KEY[name]]}${retrograde ? " (R)" : ""} ${integerDegree(degree)}°`; const body = PLANET_LABEL[name]; const suffix = retrograde ? "逆" : ""; return `${body}${suffix} ${integerDegree(degree)}°`; @@ -148,7 +151,8 @@ export function signOrdinal(sign: string): number | null { return SIGN_NUMBER[sign] ?? null; } -export function chartAriaLabel(id: ReportChartBlock["id"]): string { +export function chartAriaLabel(id: ReportChartBlock["id"], language: ReportLanguage = "zh"): string { + if (language === "en") return id === "MOON" ? "Moon reference chart" : `${id} North Indian chart`; return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`; } @@ -168,7 +172,7 @@ export function isPlanetGlyphKey(value: string): value is PlanetGlyphKey { return (PLANET_GLYPH_KEYS as readonly string[]).includes(value); } -export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel { +export function toNorthIndianChart(block: ReportChartBlock, language: ReportLanguage = "zh"): NorthIndianChartModel { const ascIndex = SIGNS.indexOf(block.ascendant.sign); const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []); const glyphsByHouse: NonNullable[] = @@ -177,7 +181,7 @@ export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartMod const signIndex = SIGNS.indexOf(planet.sign); const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1; occupantsByHouse[houseNumber - 1].push( - planetDisplayLabel(planet.name, planet.degree, planet.retrograde), + planetDisplayLabel(planet.name, planet.degree, planet.retrograde, language), ); glyphsByHouse[houseNumber - 1].push({ key: PLANET_GLYPH_KEY[planet.name], @@ -193,7 +197,7 @@ export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartMod occupantGlyphs: glyphsByHouse[index], })), planets: block.planets.map((planet) => ({ - name: PLANET_LABEL[planet.name], + name: language === "en" ? planet.name : PLANET_LABEL[planet.name], retrograde: planet.retrograde, })), }; diff --git a/frontend/src/lib/report-chart-export.tsx b/frontend/src/lib/report-chart-export.tsx index 3fb71c60..42302602 100644 --- a/frontend/src/lib/report-chart-export.tsx +++ b/frontend/src/lib/report-chart-export.tsx @@ -2,6 +2,7 @@ import { Fragment, isValidElement, type ReactNode } from "react"; import { VedicChartSvgDrawing } from "@/components/personal-report/vedic-chart-svg"; import type { NorthIndianChartModel } from "./report-chart-block"; import { projectOrdinaryReportMarkdown } from "./report-public-projection"; +import type { ReportLanguage } from "./report-language"; function escapeXml(value: unknown): string { return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); @@ -30,8 +31,8 @@ function serializeSvgNode(node: ReactNode): string { * attributes survive Markdown viewers without the application's stylesheet. * Clipping definitions are not in the ordinary SVG allowlist; remove them rather * than widening that security boundary. The renderer already bounds occupants. */ -export function reportChartExportSvg(chart: NorthIndianChartModel, ariaLabel: string): string { - const rendered = serializeSvgNode(VedicChartSvgDrawing({ chart, ariaLabel, chartId: "export-chart" })) +export function reportChartExportSvg(chart: NorthIndianChartModel, ariaLabel: string, language: ReportLanguage = "zh"): string { + const rendered = serializeSvgNode(VedicChartSvgDrawing({ chart, ariaLabel, chartId: "export-chart", language })) .replace(/[\s\S]*?<\/defs>/g, "") .replace(/ clip-path="[^"]*"/g, "") .replace(/; export type ReportExportPreset = "common" | "charts" | "tables"; -export function chartBlockText(block: ReportChartBlock): string { - const chart = toNorthIndianChart(block); - return `#### ${chartAriaLabel(block.id)}\n\n${chart.houses.map(house => - `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`, - ).join("\n")}`; +function houseLine(house: { houseNumber: number; sign: string; occupants: readonly string[] }, language: ReportLanguage): string { + return language === "en" + ? `- House ${house.houseNumber} · ${house.sign}: ${house.occupants.join(", ") || "—"}` + : `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`; } -function tableCell(value: ReportFactTable["rows"][number]["cells"][number]): string { - return factCellText(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); +export function chartBlockText(block: ReportChartBlock, language: ReportLanguage = "zh"): string { + const chart = toNorthIndianChart(block, language); + return `#### ${chartAriaLabel(block.id, language)}\n\n${chart.houses.map(house => houseLine(house, language)).join("\n")}`; } -function tableGrid(table: Pick): string { +function tableCell(value: ReportFactTable["rows"][number]["cells"][number], language: ReportLanguage): string { + return factCellText(value, language).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); +} + +function tableGrid(table: Pick, language: ReportLanguage): string { if (!table.rows.length) return ""; + const cell = (value: ReportFactTable["rows"][number]["cells"][number]) => tableCell(value, language); return [ - `| ${table.columns.map(tableCell).join(" | ")} |`, + `| ${table.columns.map(cell).join(" | ")} |`, `| ${table.columns.map(() => "---").join(" | ")} |`, - ...table.rows.map(row => `| ${row.cells.map(tableCell).join(" | ")} |`), + ...table.rows.map(row => `| ${row.cells.map(cell).join(" | ")} |`), ].join("\n"); } /** Same labels, precision, missing values and subtables as ReportFactTables; no provenance paths. */ -export function factTableToMarkdown(group: ReportFactTable): string { +export function factTableToMarkdown(group: ReportFactTable, language: ReportLanguage = "zh"): string { return projectOrdinaryReportMarkdown([ `## ${group.title}`, - FACT_STATUS_LABELS[group.claimStatus], + factStatusLabel(group.claimStatus, language), group.note, - tableGrid(group), - ...(group.subtables ?? []).flatMap(table => [`### ${table.title}`, table.note, tableGrid(table)]), - ].filter(Boolean).join("\n\n")); + tableGrid(group, language), + ...(group.subtables ?? []).flatMap(table => [`### ${table.title}`, table.note, tableGrid(table, language)]), + ].filter(Boolean).join("\n\n"), language); } -export function buildReportExportBlocks({ markdown, charts = [], factTables = [] }: { +export function buildReportExportBlocks({ markdown, charts = [], factTables = [], language = "zh" }: { markdown: string; charts?: ReportDocumentV2["charts"]; factTables?: readonly ReportFactTable[]; + /** Which edition `markdown` and `factTables` are; labels added here follow it. */ + language?: ReportLanguage; }): ReportExportBlock[] { - const projected = projectOrdinaryReportMarkdown(markdown); + const projected = projectOrdinaryReportMarkdown(markdown, language); const outline = buildLongformOutline(projected); const blocks: ReportExportBlock[] = []; function add(id: string, group: ReportExportBlock["group"], title: string, text: string) { @@ -59,7 +67,7 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = [] } // sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's // leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose. - if (outline.sourcePreamble) add("body:lead", "body", "摘要", stripReportChartBlocks(outline.sourcePreamble)); + if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", stripReportChartBlocks(outline.sourcePreamble)); for (const section of outline.sections) { add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown)); } @@ -74,21 +82,20 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = [] const preceding = projected.slice(0, match.index); const svg = preceding.match(/(]*>[\s\S]*?<\/svg>)\s*$/i)?.[1]; const safeSvg = svg?.slice(svg.lastIndexOf(" house.occupants.length)) continue; chartIds.add(chart.id); - const title = chartAriaLabel(chart.id); - const text = `#### ${title}\n\n${chart.houses.map(house => - `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`, - ).join("\n")}`; - add(`chart:${chart.id}`, "chart", title, projectOrdinaryReportMarkdown(`${text}\n\n${reportChartExportSvg(chart, title)}`)); + const title = chartAriaLabel(chart.id, language); + // Occupants and signs of the structured layer are engine English names in both editions. + const text = `#### ${title}\n\n${chart.houses.map(house => houseLine(house, language)).join("\n")}`; + add(`chart:${chart.id}`, "chart", title, projectOrdinaryReportMarkdown(`${text}\n\n${reportChartExportSvg(chart, title, language)}`, language)); } - for (const table of factTables) add(`table:${table.id}`, "table", table.title, factTableToMarkdown(table)); + for (const table of factTables) add(`table:${table.id}`, "table", table.title, factTableToMarkdown(table, language)); return blocks; } @@ -106,7 +113,8 @@ export function presetSelection(preset: ReportExportPreset, blocks: readonly Rep } /** downloadMarkdownReport appends .md; returning an extension here would duplicate it. */ -export function exportFilename(reportDate: string | null | undefined, isFull: boolean): string { +export function exportFilename(reportDate: string | null | undefined, isFull: boolean, language: ReportLanguage = "zh"): string { + if (language === "en") return `${personalReportMarkdownFilename(reportDate, "en")}${isFull ? "" : "-excerpt"}-EN`; return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`; } diff --git a/frontend/src/lib/report-fact-table-display.ts b/frontend/src/lib/report-fact-table-display.ts index 238bb30d..f4378b78 100644 --- a/frontend/src/lib/report-fact-table-display.ts +++ b/frontend/src/lib/report-fact-table-display.ts @@ -1,4 +1,5 @@ import type { ReportFactTable } from "./report-fact-table-schema"; +import type { ReportLanguage } from "./report-language"; export const FACT_STATUS_LABELS = { multi_system_consensus: "多系统共识", @@ -9,6 +10,20 @@ export const FACT_STATUS_LABELS = { blocked: "暂不可用", } as const; -export function factCellText(cell: ReportFactTable["rows"][number]["cells"][number]): string { +export const FACT_STATUS_LABELS_EN: Record = { + multi_system_consensus: "Multi-system agreement", + single_system_inference: "Single-system inference", + parameter_sensitive: "Setting-sensitive", + unclosed_divisional_chart: "Divisional chart not fully checked", + user_history_verification_required: "Needs checking against past events", + blocked: "Not available", +}; + +export function factStatusLabel(status: keyof typeof FACT_STATUS_LABELS, language: ReportLanguage = "zh"): string { + return (language === "en" ? FACT_STATUS_LABELS_EN : FACT_STATUS_LABELS)[status]; +} + +export function factCellText(cell: ReportFactTable["rows"][number]["cells"][number], language: ReportLanguage = "zh"): string { + if (language === "en" && typeof cell === "boolean") return cell ? "Yes" : "No"; return cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : typeof cell === "number" ? String(Number(cell.toFixed(2))) : cell; } diff --git a/frontend/src/lib/report-fact-table-values.ts b/frontend/src/lib/report-fact-table-values.ts index b3a8faf7..0064348b 100644 --- a/frontend/src/lib/report-fact-table-values.ts +++ b/frontend/src/lib/report-fact-table-values.ts @@ -34,3 +34,20 @@ export const isBlocked = (value: unknown) => { const raw = record(value); return raw.status === "blocked" || raw.status === "missing_in_local" || raw.claimStatus === "blocked" || raw.claim_status === "blocked"; }; + +/** English edition: engine keys are already English names; `Lagna` reads as Ascendant. */ +export function planetEn(value: unknown): string | null { + if (typeof value !== "string") return null; + if (value === "Lagna") return "Ascendant"; + return value in PLANET_ZH ? value : null; +} + +/** English sign name from an engine key, a Chinese sign name, or a 0-based index. */ +export function signEn(value: unknown, index?: unknown): string | null { + if (typeof value === "string") { + if (value in SIGN_ZH) return value; + const match = SIGN_ORDER.find(name => value === SIGN_ZH[name] || value === `${SIGN_ZH[name]}座`); + if (match) return match; + } + return typeof index === "number" && Number.isInteger(index) && index >= 0 && index < 12 ? SIGN_ORDER[index] : null; +} diff --git a/frontend/src/lib/report-fact-tables.ts b/frontend/src/lib/report-fact-tables.ts index c57d9225..61653d65 100644 --- a/frontend/src/lib/report-fact-tables.ts +++ b/frontend/src/lib/report-fact-tables.ts @@ -1,6 +1,7 @@ import { reportFactTablesSchema, type ReportFactTable, type ReportFactSubtable } from "./report-fact-table-schema"; import { SIGN_ORDER, SIGN_ZH } from "./chart-view-labels"; -import { at, date, decimal, entries, isBlocked, list, planet, position, readable, record, sign, yesNo } from "./report-fact-table-values"; +import { containsHan, type ReportLanguage } from "./report-language"; +import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable, record, sign, signEn, yesNo } from "./report-fact-table-values"; const SOURCES = [ ["vimshottari", "Vimshottari 主运", "timing_and_predictive_systems.dasha", "dasha", ["主运", "起", "止", "年数", "当前"]], @@ -14,6 +15,47 @@ const SOURCES = [ ] as const; const NOTE = "原始计算供核对,不增加结论的确定性。"; const EMPTY = "本次没有可展示的数据,不补算、不补写。"; + +/** + * English edition of every label above and below, from the same packet + * (TASK-report-english-edition-20260929). Ids and source paths are shared, so + * both editions list exactly the same rows. + */ +const EN_SOURCES: Readonly> = { + vimshottari: ["Vimshottari mahadashas", ["Mahadasha", "Start", "End", "Years", "Current"]], + ashtakavarga: ["SAV Ashtakavarga", ["Sign", "SAV points"]], + shadbala: ["Shadbala", ["Planet", "Sthana", "Dig", "Kala", "Chesta", "Naisargika", "Drik", "Total"]], + functional_roles: ["Functional benefics and malefics", ["Group", "Planet"]], + avasthas: ["Avasthas (planetary states)", ["Planet", "Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"]], + special_points: ["Upagrahas and special lagnas", ["Name", "Sign", "Degree"]], + sade_sati: ["Sade Sati phases", ["Phase", "Sign"]], + annual: ["Annual chart", ["Planet", "Sign", "Degree"]], +}; +const EN_NOTE = "Raw calculations for checking; they do not make any conclusion more certain."; +const EN_EMPTY = "Nothing to show this time; nothing is recalculated or filled in."; + +type Labels = { + note: string; + empty: string; + planet: (value: unknown, chinese?: unknown) => string | null; + sign: (value: unknown, chinese?: unknown, index?: unknown) => string | null; + yesNo: (value: unknown) => string | null; + /** Free text from the engine; the English edition drops anything still in Chinese. */ + text: (value: unknown) => string | null; +}; + +const ZH_LABELS: Labels = { note: NOTE, empty: EMPTY, planet, sign, yesNo, text: readable }; +const EN_LABELS: Labels = { + note: EN_NOTE, + empty: EN_EMPTY, + planet: (value) => planetEn(value), + sign: (value, _chinese, index) => signEn(value, index), + yesNo: (value) => (typeof value === "boolean" ? (value ? "Yes" : "No") : null), + text: (value) => { + const label = readable(value); + return label && !containsHan(label) ? label : null; + }, +}; const POINT_NAMES: Record = { Kaala: "Kaala", Mrityu: "Mrityu", Artha_Praharaka: "Artha Praharaka", Yama_Ghantaka: "Yama Ghantaka", Gulika: "Gulika", Maandi: "Maandi", Dhuma: "Dhuma", Vyatipata: "Vyatipata", Parivesha: "Parivesha", Indrachapa: "Indrachapa", Upaketu: "Upaketu", @@ -21,15 +63,22 @@ const POINT_NAMES: Record = { }; type Grid = Pick; const add = (grid: Grid, sourcePath: string, cells: (string | null)[]) => grid.rows.push({ sourcePath, cells }); -const subtable = (id: string, title: string, sourcePath: string, columns: string[]): ReportFactSubtable => ({ id, title, sourcePath, columns, rows: [], note: NOTE }); /** Server-owned, explicit presentation columns; provenance is never a visible cell. */ -export function assembleReportFactTables(packet: unknown): ReportFactTable[] { +export function assembleReportFactTables(packet: unknown, language: ReportLanguage = "zh"): ReportFactTable[] { + const en = language === "en"; + const L = en ? EN_LABELS : ZH_LABELS; + const subtable = (id: string, title: string, sourcePath: string, columns: string[]): ReportFactSubtable => ({ id, title, sourcePath, columns, rows: [], note: L.note }); + const position = (value: unknown) => { + const raw = record(value); + return [L.sign(raw.sign, raw.sign_cn, raw.sign_idx), decimal(raw.degree_in_sign ?? raw.sign_degree)] as const; + }; const root = record(packet); const worksheets = record(root.worksheets); const modules = record(record(root.chart).modules ?? root.modules); const tables: ReportFactTable[] = []; - for (const [id, title, key, moduleKey, columns] of SOURCES) { + for (const [id, zhTitle, key, moduleKey, zhColumns] of SOURCES) { + const [title, columns] = en ? EN_SOURCES[id] : [zhTitle, zhColumns]; const worksheetValue = at(worksheets, key); const rootValue = at(root, key); const source = worksheetValue ?? rootValue ?? (moduleKey ? at(modules, moduleKey) : modules); @@ -41,7 +90,7 @@ export function assembleReportFactTables(packet: unknown): ReportFactTable[] { const claimStatus = isBlocked(raw) ? "blocked" : declared === "unclosed_divisional_chart" || declared === "user_history_verification_required" || declared === "single_system_inference" || declared === "multi_system_consensus" ? declared : "parameter_sensitive"; - const table: ReportFactTable = { id, title, claimStatus, sourcePath, note: NOTE, columns: [...columns], rows: [] }; + const table: ReportFactTable = { id, title, claimStatus, sourcePath, note: L.note, columns: [...columns], rows: [] }; const children: ReportFactSubtable[] = []; if (claimStatus !== "blocked") { if (id === "vimshottari") { @@ -50,20 +99,26 @@ export function assembleReportFactTables(packet: unknown): ReportFactTable[] { const path = `${sourcePath}.timeline[${index}]`; if (!Object.keys(period).length || isBlocked(period)) return; // `years` can mean the birth balance. Never infer the full duration from dates. - add(table, path, [planet(period.lord, period.lord_cn), date(period.start), date(period.end), decimal(period.full_years), yesNo(period.is_current)]); - if (period.is_balance === true && decimal(period.balance_years) !== null) table.note += ` 出生时${planet(period.lord, period.lord_cn) ?? "首段主运"}剩余 ${decimal(period.balance_years)} 年。`; + add(table, path, [L.planet(period.lord, period.lord_cn), date(period.start), date(period.end), decimal(period.full_years), L.yesNo(period.is_current)]); + if (period.is_balance === true && decimal(period.balance_years) !== null) { + table.note += en + ? ` At birth ${L.planet(period.lord) ?? "the first mahadasha"} had ${decimal(period.balance_years)} years left.` + : ` 出生时${planet(period.lord, period.lord_cn) ?? "首段主运"}剩余 ${decimal(period.balance_years)} 年。`; + } if (period.is_current !== true) return; - const ad = subtable("antardasha", "当前主运下分运", path, ["分运", "起", "止", "当前"]); + const ad = en ? subtable("antardasha", "Antardashas in the current mahadasha", path, ["Antardasha", "Start", "End", "Current"]) + : subtable("antardasha", "当前主运下分运", path, ["分运", "起", "止", "当前"]); list(period.antardasha_timeline).forEach((entry, ai) => { const part = record(entry); const partPath = `${path}.antardasha_timeline[${ai}]`; if (!Object.keys(part).length || isBlocked(part)) return; - add(ad, partPath, [planet(part.lord, part.lord_cn), date(part.start), date(part.end), yesNo(part.is_current)]); + add(ad, partPath, [L.planet(part.lord, part.lord_cn), date(part.start), date(part.end), L.yesNo(part.is_current)]); if (part.is_current !== true) return; - const pd = subtable("pratyantar", "当前分运下小运", partPath, ["小运", "起", "止", "当前"]); + const pd = en ? subtable("pratyantar", "Pratyantardashas in the current antardasha", partPath, ["Pratyantardasha", "Start", "End", "Current"]) + : subtable("pratyantar", "当前分运下小运", partPath, ["小运", "起", "止", "当前"]); list(part.pratyantar_dasha_timeline).forEach((value, pi) => { const small = record(value); - if (Object.keys(small).length && !isBlocked(small)) add(pd, `${partPath}.pratyantar_dasha_timeline[${pi}]`, [planet(small.lord, small.lord_cn), date(small.start), date(small.end), yesNo(small.is_current)]); + if (Object.keys(small).length && !isBlocked(small)) add(pd, `${partPath}.pratyantar_dasha_timeline[${pi}]`, [L.planet(small.lord, small.lord_cn), date(small.start), date(small.end), L.yesNo(small.is_current)]); }); children.push(pd); }); @@ -71,31 +126,35 @@ export function assembleReportFactTables(packet: unknown): ReportFactTable[] { }); } else if (id === "ashtakavarga") { for (const [name, score] of entries(at(raw, "sav.scores"))) { - const label = sign(name); + const label = L.sign(name); if (label) add(table, `${sourcePath}.sav.scores.${name}`, [label, decimal(score)]); } - const bav = subtable("bav", "BAV 八分力", `${sourcePath}.bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); + const bav = en ? subtable("bav", "BAV Ashtakavarga", `${sourcePath}.bav`, ["Planet", ...SIGN_ORDER]) + : subtable("bav", "BAV 八分力", `${sourcePath}.bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); for (const [name, value] of entries(raw.bav)) { if (!planet(name) || isBlocked(value)) continue; const bindus = list(record(value).bindus); - add(bav, `${sourcePath}.bav.${name}`, [planet(name), ...SIGN_ORDER.map((_, index) => decimal(bindus[index]))]); + add(bav, `${sourcePath}.bav.${name}`, [L.planet(name), ...SIGN_ORDER.map((_, index) => decimal(bindus[index]))]); } if (raw.bav != null) children.push(bav); } else if (id === "shadbala") { for (const [name, value] of entries(raw.planets)) { if (!planet(name) || isBlocked(value)) continue; - add(table, `${sourcePath}.planets.${name}`, [planet(name), ...["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala", "total_virupas"].map(field => decimal(at(value, field)))]); + add(table, `${sourcePath}.planets.${name}`, [L.planet(name), ...["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala", "total_virupas"].map(field => decimal(at(value, field)))]); } } else if (id === "functional_roles") { - for (const [bucket, label] of [["functional_benefics", "功能吉星"], ["functional_malefics", "功能凶星"], ["functional_neutrals", "中性"], ["yogakarakas", "Yogakaraka"]]) { + const buckets = en + ? [["functional_benefics", "Functional benefic"], ["functional_malefics", "Functional malefic"], ["functional_neutrals", "Neutral"], ["yogakarakas", "Yogakaraka"]] + : [["functional_benefics", "功能吉星"], ["functional_malefics", "功能凶星"], ["functional_neutrals", "中性"], ["yogakarakas", "Yogakaraka"]]; + for (const [bucket, label] of buckets) { list(raw[bucket]).forEach((name, index) => { - if (planet(name)) add(table, `${sourcePath}.${bucket}[${index}]`, [label, planet(name)]); + if (planet(name)) add(table, `${sourcePath}.${bucket}[${index}]`, [label, L.planet(name)]); }); } } else if (id === "avasthas") { for (const [name, value] of entries(raw)) { if (!planet(name) || isBlocked(value)) continue; - add(table, `${sourcePath}.${name}`, [planet(name, record(value).planet_cn), ...["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => readable(at(value, `avasthas.${kind}.state`)))]); + add(table, `${sourcePath}.${name}`, [L.planet(name, record(value).planet_cn), ...["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => L.text(at(value, `avasthas.${kind}.state`)))]); } } else if (id === "special_points") { for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) { @@ -103,35 +162,41 @@ export function assembleReportFactTables(packet: unknown): ReportFactTable[] { for (const [name, value] of entries(at(source, kind))) { if (POINT_NAMES[name] && !isBlocked(value)) add(table, `${sourcePath}.${kind}.${name}`, [POINT_NAMES[name], ...position(value)]); if (name === "sarpa_drekkana") for (const [body, point] of entries(value)) { - if (planet(body) && !isBlocked(point)) add(table, `${sourcePath}.${kind}.${name}.${body}`, [`Sarpa Drekkana · ${planet(body)}`, ...position(point)]); + if (planet(body) && !isBlocked(point)) add(table, `${sourcePath}.${kind}.${name}.${body}`, [`Sarpa Drekkana · ${L.planet(body)}`, ...position(point)]); } } } } else if (id === "sade_sati") { - for (const [phase, label] of [["rising", "上升"], ["peak", "高峰"], ["setting", "下降"]]) { - if (at(raw, `phases.${phase}`) !== undefined) add(table, `${sourcePath}.phases.${phase}`, [label, sign(at(raw, `phases.${phase}`))]); + const phases = en ? [["rising", "Rising"], ["peak", "Peak"], ["setting", "Setting"]] : [["rising", "上升"], ["peak", "高峰"], ["setting", "下降"]]; + for (const [phase, label] of phases) { + if (at(raw, `phases.${phase}`) !== undefined) add(table, `${sourcePath}.phases.${phase}`, [label, L.sign(at(raw, `phases.${phase}`))]); } - table.note = `当前是否在进行中:${yesNo(raw.is_active) ?? "本次未返回"}。这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。`; + table.note = en + ? `In progress now: ${L.yesNo(raw.is_active) ?? "not returned this time"}. Only the phase frame returned by this calculation is listed; there are no start and end dates for the three phases yet, so no timing can be read from it.` + : `当前是否在进行中:${yesNo(raw.is_active) ?? "本次未返回"}。这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。`; } else if (id === "annual") { const annual = record(at(raw, "annual_chart.data")); if (!isBlocked(raw.annual_chart)) { - table.note += ` 年度上升:${sign(annual.asc_sign, annual.asc_sign_cn, annual.asc_sign_idx) ?? "本次未返回"};年龄:${decimal(annual.age) ?? "本次未返回"}。`; + table.note += en + ? ` Annual ascendant: ${L.sign(annual.asc_sign, annual.asc_sign_cn, annual.asc_sign_idx) ?? "not returned this time"}; age: ${decimal(annual.age) ?? "not returned this time"}.` + : ` 年度上升:${sign(annual.asc_sign, annual.asc_sign_cn, annual.asc_sign_idx) ?? "本次未返回"};年龄:${decimal(annual.age) ?? "本次未返回"}。`; for (const [name, value] of entries(annual.planets)) { - if (planet(name) && !isBlocked(value)) add(table, `${sourcePath}.annual_chart.data.planets.${name}`, [planet(name, record(value).name_cn), ...position(value)]); + if (planet(name) && !isBlocked(value)) add(table, `${sourcePath}.annual_chart.data.planets.${name}`, [L.planet(name, record(value).name_cn), ...position(value)]); } } - if (!table.rows.length) table.note += " 本次未返回年度行星位置,不补算。"; + if (!table.rows.length) table.note += en ? " Annual planet positions were not returned this time; nothing is recalculated." : " 本次未返回年度行星位置,不补算。"; // Annual Sahams only: do not relabel natal advanced_systems.sahams as annual facts. - const saham = subtable("sahams", "年度 Saham", `${sourcePath}.sahams.data`, ["名称", "星座", "度数"]); + const saham = en ? subtable("sahams", "Annual Sahams", `${sourcePath}.sahams.data`, ["Name", "Sign", "Degree"]) + : subtable("sahams", "年度 Saham", `${sourcePath}.sahams.data`, ["名称", "星座", "度数"]); if (!isBlocked(raw.sahams)) for (const [name, value] of entries(at(raw, "sahams.data"))) { - const label = readable(record(value).name); + const label = L.text(record(value).name) ?? (en ? `${name.replace(/_saham$/, "").replace(/_/g, " ")} Saham` : null); if (name.endsWith("_saham") && label && !isBlocked(value)) add(saham, `${saham.sourcePath}.${name}`, [label, ...position(value)]); } if (at(raw, "sahams.data") != null) children.push(saham); } } - if (children.length) table.subtables = children.map(child => ({ ...child, note: child.rows.length ? child.note : EMPTY })); - if (claimStatus === "blocked" || (!table.rows.length && !children.length && id !== "sade_sati" && id !== "annual")) table.note = EMPTY; + if (children.length) table.subtables = children.map(child => ({ ...child, note: child.rows.length ? child.note : L.empty })); + if (claimStatus === "blocked" || (!table.rows.length && !children.length && id !== "sade_sati" && id !== "annual")) table.note = L.empty; tables.push(table); } return reportFactTablesSchema.parse(tables); diff --git a/frontend/src/lib/report-language.ts b/frontend/src/lib/report-language.ts new file mode 100644 index 00000000..14869b65 --- /dev/null +++ b/frontend/src/lib/report-language.ts @@ -0,0 +1,31 @@ +/** + * The two editions of one personal report (TASK-report-english-edition-20260929). + * Both are rendered by the engine from the same packet; English is a subordinate + * edition — when it is missing or fails its checks the report is still ready in + * Chinese. + */ +export type ReportLanguage = "zh" | "en"; + +export const REPORT_LANGUAGES: readonly ReportLanguage[] = ["zh", "en"]; + +/** Any CJK ideograph. The English edition must contain none (red line 2). */ +const HAN = /[㐀-鿿豈-﫿]/; + +export function containsHan(text: string): boolean { + return HAN.test(text); +} + +export function readReportLanguage(value: unknown): ReportLanguage { + return value === "en" ? "en" : "zh"; +} + +/** + * Numbers in reading order: degrees, dates, scores, house numbers. Two editions + * rendered from one packet must produce the same sequence (red line 3). + */ +export function numericTokens(text: string): string[] { + return text.match(/-?\d+(?:\.\d+)?/g) ?? []; +} + +/** `legacy`: generated before the English edition existed; `unavailable`: attempted and dropped. */ +export type EnglishEditionStatus = "ready" | "unavailable" | "legacy"; diff --git a/frontend/src/lib/report-public-projection.ts b/frontend/src/lib/report-public-projection.ts index 243312b3..685dc734 100644 --- a/frontend/src/lib/report-public-projection.ts +++ b/frontend/src/lib/report-public-projection.ts @@ -8,6 +8,8 @@ * kind fail-safes to the same ordinary projection instead of leaking. */ +import type { ReportLanguage } from "./report-language"; + export const REPORT_DOCUMENT_KINDS = [ "chat_export", "personal_report_detail", @@ -71,6 +73,15 @@ const LIMITATION_ORDER = [ const LIMITATION_HEADING = "需要知道的限制"; +/** English edition of the same four notes, keyed by the Chinese copy the analysis collects. */ +const ENGLISH_LIMITATION_COPY: Readonly> = { + [ORDINARY_LIMITATION_COPY.preciseTiming]: "This report cannot point to an exact day.", + [ORDINARY_LIMITATION_COPY.missingEvidence]: "Some supporting evidence is still missing, so related statements are not firm predictions.", + [ORDINARY_LIMITATION_COPY.techniqueOpen]: "Some judgements are not yet closed and are not stated as firm conclusions.", + [ORDINARY_LIMITATION_COPY.statusLimited]: "What can be read this time is what is written here.", +}; +const ENGLISH_LIMITATION_HEADING = "Limitations to keep in mind"; + const INTERNAL_KEYS = new Set([ "technique_truth", "workflow_route", @@ -205,8 +216,16 @@ export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ /(?:\b[A-Za-z]{2,}(?:'[A-Za-z]+)?\b\s+){11}\b[A-Za-z]{2,}(?:'[A-Za-z]+)?\b/, ]; -export function ordinaryOutputLeaks(text: string): string[] { - return ORDINARY_OUTPUT_LEAK_PATTERNS.filter((pattern) => pattern.test(text)).map((pattern) => pattern.source); +/** + * The English edition is prose in English, so the "twelve English words in a + * row" paragraph rule (the last ordinary pattern) cannot apply to it. Every + * other rule — audit vocabulary, internal keys, unsafe markup — still does. + */ +export const ENGLISH_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = ORDINARY_OUTPUT_LEAK_PATTERNS.slice(0, -1); + +export function ordinaryOutputLeaks(text: string, language: ReportLanguage = "zh"): string[] { + const patterns = language === "en" ? ENGLISH_OUTPUT_LEAK_PATTERNS : ORDINARY_OUTPUT_LEAK_PATTERNS; + return patterns.filter((pattern) => pattern.test(text)).map((pattern) => pattern.source); } export function isOrdinaryReportDocumentKind(kind: ReportDocumentKind): kind is OrdinaryReportDocumentKind { @@ -260,10 +279,10 @@ export function projectChatExportMarkdown(input: ChatExportInput): string { return lines.join("\n").trim(); } -export function projectOrdinaryReportMarkdown(markdown: string): string { - const analyzed = analyzeOrdinaryReportMarkdown(markdown); +export function projectOrdinaryReportMarkdown(markdown: string, language: ReportLanguage = "zh"): string { + const analyzed = analyzeOrdinaryReportMarkdown(markdown, language === "en" ? "report_en" : "report"); if (!analyzed.changed && analyzed.limitations.length === 0) return markdown; - return appendLimitations(analyzed.prose, analyzed.limitations); + return appendLimitations(analyzed.prose, analyzed.limitations, language); } export function projectOrdinarySnippet(text: string): string { @@ -338,17 +357,18 @@ function limitationsFromSignals(input: ChatExportInput): string[] { return notes; } -type ProjectionScope = "report" | "chat_export"; +type ProjectionScope = "report" | "report_en" | "chat_export"; function outputLeaks(text: string, scope: ProjectionScope): boolean { - const patterns = scope === "report" ? ORDINARY_OUTPUT_LEAK_PATTERNS : SHARED_OUTPUT_LEAK_PATTERNS; + const patterns = scope === "report" ? ORDINARY_OUTPUT_LEAK_PATTERNS + : scope === "report_en" ? ENGLISH_OUTPUT_LEAK_PATTERNS : SHARED_OUTPUT_LEAK_PATTERNS; return patterns.some((pattern) => pattern.test(text)); } function adjacentInternalTableIndexes(blocks: Block[], scope: ProjectionScope): Set { const drop = new Set(); blocks.forEach((block, index) => { - if (scope !== "report" || block.type !== "table" || !tableIsInternal(block.text, scope)) return; + if (scope === "chat_export" || block.type !== "table" || !tableIsInternal(block.text, scope)) return; drop.add(index); let cursor = index - 1; while (cursor >= 0 && blocks[cursor]?.type === "blank") cursor -= 1; @@ -541,11 +561,14 @@ function collectLimitations(text: string, into: Set) { } } -function appendLimitations(prose: string, limitations: readonly string[]): string { - const notes = orderedLimitations(limitations).filter((note) => !prose.includes(note)); +function appendLimitations(prose: string, limitations: readonly string[], language: ReportLanguage = "zh"): string { + const notes = orderedLimitations(limitations) + .map((note) => (language === "en" ? ENGLISH_LIMITATION_COPY[note] ?? note : note)) + .filter((note) => !prose.includes(note)); const body = prose.replace(/\s+$/u, ""); if (notes.length === 0) return body; - const section = [`## ${LIMITATION_HEADING}`, "", ...notes].join("\n"); + const heading = language === "en" ? ENGLISH_LIMITATION_HEADING : LIMITATION_HEADING; + const section = [`## ${heading}`, "", ...notes].join("\n"); return body ? `${body}\n\n${section}` : section; } diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 57e9389b..1e4b8542 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -500,7 +500,10 @@ test("the report TOC is a persistent right-hand rail that leaves the paper palet ); // Built from the outline's own heading ids — no second slugger, and nothing // that would need report-chart-grid-rehype to change (BUG-616/617). - assert.match(viewSource, //); + // 原值:`//` + // 新值:同一 headings 之外允许并只允许多一个 `language` 属性 + // 原因:TASK-report-english-edition-20260929 E6——目录标签随报告语言切换;目录仍只从同一份 outline 的 heading id 构建 + assert.match(viewSource, //); assert.match(viewSource, /aria-current=\{item\.id === activeId \? "location" : undefined\}/); assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/, "BUG-616/617 grid stays wired"); diff --git a/frontend/tests/report-english-edition.test.tsx b/frontend/tests/report-english-edition.test.tsx new file mode 100644 index 00000000..3ae79405 --- /dev/null +++ b/frontend/tests/report-english-edition.test.tsx @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; + +import { ReportActions } from "../src/components/personal-report/report-actions"; +import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer"; +import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page"; +import { acceptEnglishEdition } from "../src/lib/personal-report-english-edition"; +import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix"; +import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot"; +import { resolveReportRead, type EnglishEditionRead } from "../src/lib/personal-report-route-core"; +import type { PersonalReportRecord } from "../src/lib/personal-report-service-core"; +import { buildReportExportBlocks, exportFilename } from "../src/lib/report-export-blocks"; +import { assembleReportFactTables } from "../src/lib/report-fact-tables"; +import { assembleLongformCharts } from "../src/lib/personal-report-generation"; +import { containsHan, numericTokens } from "../src/lib/report-language"; +import { ordinaryOutputLeaks, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; + +// TASK-report-english-edition-20260929. Fixtures are real engine output for a +// fictional birth (AGENTS §7-4). +Object.assign(globalThis, { React }); + +const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")); +const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8")); + +const ENGLISH_PROSE = "The Moon sits in the ninth house and its dasha opens long periods of study, travel and teaching that reward patience."; + +test("English projection keeps English prose but still drops audit vocabulary", () => { + // Twelve English words in a row are a leak in the Chinese edition, and ordinary prose in the English one. + assert.ok(ordinaryOutputLeaks(ENGLISH_PROSE).length > 0); + assert.deepEqual(ordinaryOutputLeaks(ENGLISH_PROSE, "en"), []); + const source = `## Summary\n\n${ENGLISH_PROSE}\n\nThis section is parameter_sensitive and blocked.\n`; + const projected = projectOrdinaryReportMarkdown(source, "en"); + assert.match(projected, /ninth house/); + assert.doesNotMatch(projected, /parameter_sensitive|blocked/); + assert.match(projected, /## Limitations to keep in mind/); + assert.equal(containsHan(projected), false, "limitations are written in English too"); + // The Chinese edition is unchanged by the new parameter. + assert.equal(projectOrdinaryReportMarkdown(zhReader.markdown), projectOrdinaryReportMarkdown(zhReader.markdown, "zh")); +}); + +test("English fact tables list the same rows and numbers as the Chinese ones, with no Han", () => { + const zh = assembleReportFactTables(engine); + const en = assembleReportFactTables(engine, "en"); + assert.equal(containsHan(JSON.stringify(en)), false); + assert.deepEqual(en.map(table => table.id), zh.map(table => table.id)); + for (const [index, table] of en.entries()) { + const other = zh[index]; + assert.equal(table.claimStatus, other.claimStatus, table.id); + assert.equal(table.columns.length, other.columns.length, table.id); + assert.deepEqual(table.rows.map(row => row.sourcePath), other.rows.map(row => row.sourcePath), table.id); + assert.deepEqual(numericTokens(JSON.stringify(table.rows.map(row => row.cells))), numericTokens(JSON.stringify(other.rows.map(row => row.cells))), table.id); + assert.deepEqual((table.subtables ?? []).map(sub => sub.rows.length), (other.subtables ?? []).map(sub => sub.rows.length), table.id); + } + // Default stays the Chinese edition byte for byte. + assert.deepEqual(assembleReportFactTables(engine, "zh"), zh); +}); + +test("the generator accepts only a clean English edition and otherwise records why", () => { + const ok = acceptEnglishEdition(`# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, undefined, engine); + assert.ok(ok.english); + assert.equal(ok.english.contentSha256, hashLongformMarkdown(ok.english.markdown)); + assert.equal(containsHan(JSON.stringify(ok.english.factTables)), false); + assert.deepEqual(acceptEnglishEdition("# 报告\n\nMixed 太阳 text\n", undefined, engine), { englishUnavailable: "han_leak" }); + assert.deepEqual(acceptEnglishEdition("# Report\n\nThis is parameter_sensitive.\n", undefined, engine), { englishUnavailable: "public_leak" }); + assert.deepEqual(acceptEnglishEdition(undefined, "render_error", engine), { englishUnavailable: "render_error" }); + assert.deepEqual(acceptEnglishEdition(undefined, "Not A Code!", engine), { englishUnavailable: "engine_missing" }); +}); + +const identity = { + reportId: "11111111-1111-4111-8111-111111111111", + userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + requestId: "22222222-2222-4222-8222-222222222222", +}; + +function snapshot(version: 1 | 2, extra: Record = {}) { + const markdown = zhReader.markdown as string; + return { + kind: "longform_calculation_snapshot", version, ...identity, markdown, + contentSha256: hashLongformMarkdown(markdown), + factTables: assembleReportFactTables(engine), + charts: assembleLongformCharts(engine, "ev-cover-longform"), + readerDashaApplicability: "x", + ...extra, + }; +} + +test("snapshots: v1 still reads; v2 carries exactly one of the English edition or the reason it is missing", () => { + assert.equal(validateLongformSnapshot(snapshot(1), identity).version, 1); + const english = acceptEnglishEdition(`# Report\n\n${ENGLISH_PROSE}\n`, undefined, engine).english!; + assert.equal(validateLongformSnapshot(snapshot(2, { english }), identity).version, 2); + assert.equal(validateLongformSnapshot(snapshot(2, { englishUnavailable: "han_leak" }), identity).version, 2); + assert.throws(() => validateLongformSnapshot(snapshot(2), identity)); + assert.throws(() => validateLongformSnapshot(snapshot(2, { english, englishUnavailable: "han_leak" }), identity)); + assert.throws(() => validateLongformSnapshot(snapshot(2, { english: { ...english, contentSha256: "0".repeat(64) } }), identity)); + assert.throws(() => validateLongformSnapshot(snapshot(1, { english }), identity), "v1 cannot carry English"); +}); + +function readyRecord(): PersonalReportRecord { + return { + id: identity.reportId, userId: identity.userId, sessionId: null, chartProfileId: null, + requestId: identity.requestId, requestFingerprint: "f".repeat(64), reportType: "personal_full", + presentationMode: "default", depth: "standard", requestedThemes: ["career"], status: "ready", + evidenceHash: null, reportDocument: null, cardSummary: null, failureCode: null, + createdAt: "2026-09-29T00:00:00.000Z", completedAt: "2026-09-29T00:01:00.000Z", updatedAt: "2026-09-29T00:01:00.000Z", + } as unknown as PersonalReportRecord; +} + +async function read(english?: () => Promise) { + return resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", origin: null, allowedOrigins: [], + userId: identity.userId, reportId: identity.reportId, + persistence: { async getOwnedById() { return readyRecord(); } }, + loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: zhReader.markdown }), + ...(english ? { loadLongformEnglish: english } : {}), + validateReadyDocument: () => ({ ok: false }), + }); +} + +test("the read route says which editions exist and never fails Chinese over English", async () => { + const ready = await read(async () => ({ status: "ready", markdown: `# Report\n\n${ENGLISH_PROSE}\n`, factTables: assembleReportFactTables(engine, "en") })); + assert.equal(ready.status, 200); + assert.equal(ready.body.englishEdition, "ready"); + assert.match(String(ready.body.longformMarkdownEn), /ninth house/); + assert.equal(ready.body.longformMarkdown, projectOrdinaryReportMarkdown(zhReader.markdown)); + const legacy = await read(async () => ({ status: "legacy" })); + assert.equal(legacy.body.englishEdition, "legacy"); + assert.equal("longformMarkdownEn" in legacy.body, false); + const broken = await read(async () => { throw new Error("db down"); }); + assert.equal(broken.status, 200); + assert.equal(broken.body.englishEdition, "unavailable"); + assert.equal((await read()).body.englishEdition, "legacy"); +}); + +test("the reader trusts an English edition only when it is clean", () => { + const envelope = (extra: Record) => classifyReportEnvelope(200, { + report: { id: identity.reportId, status: "ready", createdAt: "2026-09-29T00:00:00.000Z" }, + longformMarkdown: zhReader.markdown, ...extra, + }); + const ready = envelope({ englishEdition: "ready", longformMarkdownEn: `# Report\n\n${ENGLISH_PROSE}\n` }); + assert.equal(ready.phase, "markdown-ready"); + assert.equal(ready.phase === "markdown-ready" && ready.englishEdition, "ready"); + const mixed = envelope({ englishEdition: "ready", longformMarkdownEn: "# Report\n\n太阳\n" }); + assert.equal(mixed.phase === "markdown-ready" && mixed.englishEdition, "unavailable"); + const legacy = envelope({}); + assert.equal(legacy.phase === "markdown-ready" && legacy.englishEdition, "legacy"); +}); + +test("English export blocks, labels and filename", () => { + const markdown = `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n\n## Strength\n\nBody.\n`; + const blocks = buildReportExportBlocks({ markdown, factTables: assembleReportFactTables(engine, "en"), charts: assembleLongformCharts(engine, "x"), language: "en" }); + assert.ok(blocks.length > 3); + assert.equal(containsHan(JSON.stringify(blocks)), false, "no Chinese label sneaks into the English export"); + assert.equal(exportFilename("2026-09-29T00:00:00Z", true, "en"), "Personal-Report-2026-09-29-EN"); + assert.equal(exportFilename("2026-09-29T00:00:00Z", false, "en"), "Personal-Report-2026-09-29-excerpt-EN"); + assert.equal(exportFilename("2026-09-29T00:00:00Z", false), "个人报告-2026-09-29-节选"); +}); + +test("the language switch only exists when both editions do", async () => { + const h = createClientLifecycleHarness(); + const picked: string[] = []; + try { + await h.render( picked.push(value)} />); + assert.ok(!h.elements().some(node => node.getAttribute("aria-label") === "报告语言")); + await h.render( picked.push(value)} />); + const english = h.elements().find(node => node.tagName === "BUTTON" && node.text === "English"); + assert.ok(english); + assert.equal(english.getAttribute("aria-pressed"), "false"); + await h.event(english); + assert.deepEqual(picked, ["en"]); + } finally { await h.close(); } +}); + +test("the export drawer suggests English for AI only when it can switch", async () => { + const blocks = buildReportExportBlocks({ markdown: zhReader.markdown }); + const h = createClientLifecycleHarness(); + let switched = 0; + try { + await h.render( {}} language="zh" englishEdition="ready" onSwitchToEnglish={() => { switched += 1; }} />); + assert.ok(h.container.text.includes("建议切到 English 再导出")); + await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.text === "切到 English")!); + assert.equal(switched, 1); + await h.render( {}} language="en" englishEdition="ready" />); + assert.ok(!h.container.text.includes("建议切到 English")); + assert.ok(h.container.text.includes("导出的是英文版")); + await h.render( {}} englishEdition="legacy" />); + assert.ok(h.container.text.includes("重新生成即可获得中英两版")); + } finally { await h.close(); } +});