From 8883f8475f395b13005f977f7d7df4d6f7be2c2e Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 13 Oct 2026 21:45:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=BC=80=E5=A4=B4=E6=94=B9?= =?UTF-8?q?=E6=88=90=E9=87=8D=E7=82=B9=E6=91=98=E8=A6=81=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新报告用围栏画出大运和事业、财富、婚恋,标题下写名字与日期。旧报告不变。 自测:pytest test_report_full_data_fix.py 10 通过;相关四份报告测试退出码 0;前端相关测试 147 通过、0 失败、0 取消;tsc 退出码 0;lint 0 error。 Co-Authored-By: Grok (agent loop) --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 14 ++ .../PROGRESS-report-focus-card-20261013.md | 35 ++++ frontend/DESIGN.md | 2 + frontend/src/app/globals.css | 76 ++++++++- .../personal-report-markdown-view.tsx | 74 ++++++-- .../personal-report/personal-report-page.tsx | 20 ++- .../personal-report/report-focus-card.tsx | 70 ++++++++ .../lib/personal-report-longform-download.ts | 3 +- .../src/lib/personal-report-route-core.ts | 14 ++ frontend/src/lib/report-export-blocks.ts | 6 +- frontend/src/lib/report-focus-block.ts | 158 ++++++++++++++++++ frontend/src/lib/report-public-projection.ts | 4 +- .../fixtures/report-focus-fictional-day.json | 9 + frontend/tests/personal-report-api.test.ts | 27 +++ frontend/tests/report-focus-card.test.ts | 112 +++++++++++++ scripts/pl9_full_data_export.py | 60 +++++-- scripts/pl9_interpretation_packs.py | 119 ++++++++----- scripts/report_focus_block.py | 53 ++++++ tests/test_report_full_data_fix.py | 76 ++++++++- 20 files changed, 856 insertions(+), 82 deletions(-) create mode 100644 docs/tasks/PROGRESS-report-focus-card-20261013.md create mode 100644 frontend/src/components/personal-report/report-focus-card.tsx create mode 100644 frontend/src/lib/report-focus-block.ts create mode 100644 frontend/tests/fixtures/report-focus-fictional-day.json create mode 100644 frontend/tests/report-focus-card.test.ts create mode 100644 scripts/report_focus_block.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e9167d..2e2a0602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-13 — 报告开头的「重点」改成一张摘要卡(待合入 staging) + +- 新生成的个人报告,标题比正文大,下面一行小字是名字和生成日期。没有名字只写日期;标题里已经有名字就不重复。 +- 开头不再堆四句行话。第一行是现在走的大运(主运、子运、次子运和起止年月),下面三行是事业、财富、婚恋,各带两个小标签。最后仍提醒:这段时间可能突出这些主题,不是确定会发生的事。 +- 导出的文件里这一块是文字,不是一串代码。以前生成的报告打开还是原来的样子。Skill 版本不变(BUG-1316)。 + ## 2026-10-13 — 新报告正文画出分盘,中文更干净,上升行和同位列能读懂(待合入 staging) - 重新生成的报告里,「本命与分盘北印度图盘」每张有数据的盘下面有一张北印度图。同一张盘只画一次。报告最末不再重复「分盘核对」。旧报告打开仍是当时保存的正文。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7794c7c0..b5da984d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17689,3 +17689,17 @@ - 复发自:无。 - 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。 +## BUG-1316 | 报告开头的「重点」没有设计,标题也没有样式 + +- 状态:fixed-pending-verify(须重新生成报告才看得到;真机清单未走,不标 resolved) +- 首次发现 / 最近更新:2026-10-13 / 2026-10-13 +- 来源:issue #55。产品负责人选 A:做成一张摘要卡,标题下写名字和日期,保留提醒句。 +- 现象:新报告开头先有一行「重点:当前主运 MD …」,接着又有「## 重点」和三条「事业 / 财富 / 婚恋」数据句,目录里再出现一次「重点」。标题和正文一样大。缩写 MD / AD / PD 直接给读者看。 +- 根因:10-08 只要求开篇有这段内容,没有规定怎么呈现。09-29 去掉封面之后,标题只剩字体。检索「重点:」「opening_focus」「当前主运 MD」当时没有记录。不是旧修复回退。 +- 修复:开篇改成一个 `jyotish-focus` 围栏。阅读页解析后画一张卡:现在走的大运、事业、财富、婚恋,尊贵和功能吉凶是两个小标签,最后是原来的提醒句。拿不到的日期写空,不新算大运或宫主。标题用显示字体,比下一章大;下面一行是名字和生成日期。名字只出现在本人打开的阅读页,公开投影和导出都不带名字。没有围栏的旧报告照旧显示。导出把围栏换成可读的字。 +- 验证:`tests/test_report_full_data_fix.py` 10 通过。另四份报告测试退出码 0。前端改到的测试 147 通过、0 失败、0 取消,其中导出冻结哈希仍在。`tsc --noEmit` 退出码 0。`npm run lint` 0 error。 +- 防复发:`tests/test_report_full_data_fix.py` 的开篇围栏用例;`frontend/tests/report-focus-card.test.ts` 用虚构日盘真实输出锁住卡片、坏数据和旧正文。 +- 相关记录:无。 +- 复发自:无。 +- 修复版本:分支 `codex/report-focus-card-20261013`,尚未合入 staging。 + diff --git a/docs/tasks/PROGRESS-report-focus-card-20261013.md b/docs/tasks/PROGRESS-report-focus-card-20261013.md new file mode 100644 index 00000000..8e9486b9 --- /dev/null +++ b/docs/tasks/PROGRESS-report-focus-card-20261013.md @@ -0,0 +1,35 @@ +# 进度 · 报告开篇重点卡(2026-10-13) + +- 分支:`codex/report-focus-card-20261013` +- 任务书:`docs/tasks/TASK-report-focus-card-20261013.md` +- 基线:任务书写的 `856bc850b` 已过时。本轮按 Claude 计划,从当时的 `origin/staging` `963276ca2fc942bc28ef9bdee3b8d0e9d3c957c0` 建分支,没有回退。 +- issue:#55,BUG-1316 + +## 做了什么 + +1. 新报告开篇改成一个 `jyotish-focus` 围栏。里面是现在的主运、子运、次子运,以及事业(第 10 宫)、财富(第 2 宫)、婚恋(第 7 宫)。日期和宫主都用报告里已经有的数,缺的写空。 +2. 阅读页把这个围栏画成一张卡。中文不出现 MD / AD / PD。提醒句留在卡的最后。围栏坏了只显示「重点数据无效」。 +3. 标题比下一章大。下面一行是「名字 · 生成于 日期」。没有名字只写日期。标题里已经有名字就不重复。名字只给登录后打开自己报告的人,公开页和导出文件都不带名字。 +4. 旧报告没有这个围栏时,正文照旧。导出把围栏换成可读的字。 + +## 检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 开篇围栏 | `.venv\Scripts\python -m pytest tests/test_report_full_data_fix.py -q` | 10 通过,退出码 0 | +| 相关报告测试 | 同解释器跑 chart blank columns、english edition、reader appendix、reader main | 退出码 0 | +| 前端改到的测试 | `node node_modules/tsx/dist/cli.mjs --test` 九个相关文件 | 147 通过,0 失败,0 取消 | +| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| 前端 lint | `frontend` 下 `npm run lint` | 0 error(125 条既有 warning,本轮未改) | +| 数据库 | 未跑 | 本轮不改表,任务书写明不需要 | + +## 偏离 + +- 中文报告大运章的表头仍有 MD / AD / PD。卡片和开篇里没有。英文测试仍要求正文里能看到 AD、PD,所以没有改那一章的表头。任务书「全文不再出现」按开篇(出生资料之前)执行。 +- 固定清单「先核对出生资料、图盘」原来也会被写进资料附录。附录单独整理时不再注入这张清单。正文开头有重点卡时也不再注入。 +- 短报告测试要显式画出第 1 章,卡片才会出现在静态结果里。正式阅读页里,完整数据版第 1 章本来就会画出来。没有把「重点」改成一打开就全部展开。 + +## 环境缺口 + +- 真机清单 `docs/testing/report-focus-card-20261013.md` 留给部署后。本机没有登录态,没有重开浏览器看深浅色。 +- 未跑前端全量测试、Python 快速门、`next build`。按任务书留给验收方。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 79d6ae0f..bf73e26d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -675,6 +675,8 @@ Action results and errors never take page layout. They are toasts from `src/lib/ - **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. - **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged. - **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. +- **报告标题 (2026-10-13, BUG-1316):** the Markdown `h1` is the report title. It uses `--font-display` at `--type-display-md` (larger than the `h2` `--type-display-sm`), weight 500, with `--space-2` above and `--space-3` below. Under it, one caption (`.personal-report-md-byline`, `--color-ink-tertiary`, `--type-caption`, body face) reads `{名字} · 生成于 YYYY-MM-DD`, or `Generated` in English. No name: date only. The title already contains the name: date only, the name is not repeated. The name comes from the signed-in read (`subjectDisplayName`); it is not added to the public projection or the export. Old reports without a name still show the date. +- **开篇重点卡 (2026-10-13, BUG-1316):** the first chapter is one card (`.personal-report-focus-card`) drawn from a `jyotish-focus` fence. The engine writes the JSON; the page only parses it. First row, 「现在走的大运」: 主运 / 子运 / 次子运, each the planet's Chinese name plus `YYYY.MM – YYYY.MM`, in a wrapping row. Then 事业 / 财富 / 婚恋: 「第 N 宫主 · 行星 · 落第 M 宫」 and two tags (`.personal-report-focus-tag`, same muted pill as `.chart-role-badge`: `--color-canvas-muted`, `--color-ink-secondary`, `--type-overline`). The last line is the caveat at `--type-caption` / `--color-ink-tertiary`. No MD / AD / PD on the card. A bad fence says 「重点数据无效」 (English "Focus data is invalid"), the same invalid line as a bad chart, and does not echo the JSON. The card wraps; it does not scroll sideways. Tokens only, so light and dark both stay readable. Reports with no fence render as before. Export turns the fence into those same words; the file has no raw JSON. The public projection keeps a public fence the way it keeps `jyotish-chart`, and does not add a name field. - **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`. - **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. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 48add232..db70b2b4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4338,9 +4338,79 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class overflow-wrap: anywhere; padding: var(--space-8) var(--space-7) var(--space-10); } -/* The report title (markdown `#`) is the md view's cover line; it takes the - heading face like the document view's `.personal-report-cover h1`. */ -.personal-report-md-article h1 { font-family: var(--font-display); } +.personal-report-md-article h1 { + margin: var(--space-2) 0 var(--space-3); + font-family: var(--font-display); + font-size: var(--type-display-md); + font-weight: 500; + line-height: 1.25; +} +.personal-report-md-byline { + margin: 0 0 var(--space-5); + color: var(--color-ink-tertiary); + font-family: var(--font-body); + font-size: var(--type-caption); + line-height: 1.5; +} +.personal-report-focus-card { + display: grid; + gap: var(--space-3); + margin: 0 0 var(--space-5); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); + overflow-wrap: anywhere; +} +.personal-report-focus-kicker { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-overline); + letter-spacing: .06em; +} +.personal-report-focus-periods, +.personal-report-focus-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.personal-report-focus-period { + min-width: 0; + flex: 1 1 8rem; + margin: 0; +} +.personal-report-focus-period strong { display: block; font-weight: 600; } +.personal-report-focus-period span { + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.personal-report-focus-domains { display: grid; gap: var(--space-3); } +.personal-report-focus-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} +.personal-report-focus-row p { margin: 0; } +.personal-report-focus-domain { display: block; font-weight: 600; } +.personal-report-focus-tag { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 var(--space-2); + border-radius: 999px; + background: var(--color-canvas-muted); + color: var(--color-ink-secondary); + font-size: var(--type-overline); + font-weight: 600; +} +.personal-report-focus-caveat { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.5; +} .personal-report-md-article h2, .personal-report-md-article h3 { scroll-margin-top: 80px; } .personal-report-md-article h2 { 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 40131040..dd647402 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -6,11 +6,13 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { fencedLanguage, fencedSource } from "@/components/markdown-code-block"; +import { ReportFocusCard } from "@/components/personal-report/report-focus-card"; import { chartAriaLabel, parseReportChartBlock, toNorthIndianChart, } from "@/lib/report-chart-block"; +import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block"; 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"; @@ -35,8 +37,14 @@ function reportUrlTransform(url: string): string { return url.startsWith("#") ? url : ""; } -function markdownComponents(headings: readonly LongformHeading[], language: ReportLanguage): Components { +function markdownComponents( + headings: readonly LongformHeading[], + language: ReportLanguage, + subjectName?: string, + createdAt?: string, +): Components { const cursor = { h2: 0, h3: 0 }; + const byline = { shown: false }; const nextId = (level: 2 | 3, children: ReactNode) => { const title = flattenText(children).trim(); const key = level === 2 ? "h2" : "h3"; @@ -58,6 +66,17 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo {children}
), + h1: ({ children }) => { + const title = flattenText(children).trim(); + const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language }); + byline.shown = true; + return ( + <> +

{children}

+ {line ?

{line}

: null} + + ); + }, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, h4: ({ children }) => { @@ -70,7 +89,15 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo ); }, pre: ({ children, ...props }) => { - if (fencedLanguage(children) !== "jyotish-chart") { + const fence = fencedLanguage(children); + if (fence === "jyotish-focus") { + const focus = parseReportFocusBlock(fencedSource(children).trim()); + if (!focus) { + return

{language === "en" ? "Focus data is invalid" : "重点数据无效"}

; + } + return ; + } + if (fence !== "jyotish-chart") { return
{children}
; } const block = parseReportChartBlock(fencedSource(children).trim()); @@ -90,10 +117,16 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo }; } -function renderMarkdown(markdown: string, headings: readonly LongformHeading[], language: ReportLanguage) { +function renderMarkdown( + markdown: string, + headings: readonly LongformHeading[], + language: ReportLanguage, + subjectName?: string, + createdAt?: string, +) { return ( (null); const [seen, setVisible] = useState(section.eager || drawn); @@ -151,10 +188,10 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({ return () => observer.disconnect(); }, [visible]); const content = useMemo( - () => (visible ? renderMarkdown(section.markdown, section.headings, language) : ( + () => (visible ? renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt) : (

{section.title}

)), - [visible, section, language], + [visible, section, language, subjectName, createdAt], ); return (
@@ -211,11 +248,20 @@ function ReportToc({ ); } -export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThrough = null }: { +export function PersonalReportMarkdownView({ + markdown, + language = "zh", + drawnThrough = null, + subjectName, + createdAt, +}: { markdown: string; language?: ReportLanguage; /** Chapter index (h2 order) through which sections render immediately; used when switching editions. */ drawnThrough?: number | null; + /** Owner-only display name. Omitted from export and from the public projection. */ + subjectName?: string; + createdAt?: string; }) { const outline = useMemo(() => buildLongformOutline(markdown), [markdown]); const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]); @@ -248,21 +294,21 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr for (const listener of reveal.listeners) listener(); }), [chapters.length, paged, reveal]); const lead = useMemo( - () => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language) : null), - [outline, language], + () => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language, subjectName, createdAt) : null), + [outline, language, subjectName, createdAt], ); const safeChapter = Math.min(chapter, Math.max(chapters.length - 1, 0)); const currentChapter = chapters[safeChapter]; const pagedChapter = useMemo(() => ( paged && currentChapter - ? renderMarkdown(currentChapter.markdown, currentChapter.headings, language) + ? renderMarkdown(currentChapter.markdown, currentChapter.headings, language, subjectName, createdAt) : null - ), [paged, currentChapter, language]); + ), [paged, currentChapter, language, subjectName, createdAt]); const printedChapters = useMemo(() => ( paged && printing - ? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language)) + ? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt)) : null - ), [paged, printing, chapters, language]); + ), [paged, printing, chapters, language, subjectName, createdAt]); const openChapter = useCallback((id: string) => { const index = chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id)); if (index >= 0) setChapter(index); @@ -318,10 +364,12 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr {outline.sections.map((section, index) => (section.eager ? null : ( )))} diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index e05cdcd0..bafd735b 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -61,6 +61,8 @@ export type ReportLoadState = markdown: string; reportId: string; createdAt: string; + /** Signed-in reader's display name. Never written into the export. */ + subjectDisplayName?: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"]; /** `legacy`: generated before the English edition existed; no switch is shown. */ @@ -145,6 +147,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor const facts = reportFactTablesSchema.safeParse(json.factTables); const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts); const english = readEnglishEdition(json); + const subjectDisplayName = readSubjectDisplayName(json); return { ...(facts.success && facts.data.length ? { factTables: facts.data } : {}), ...(charts.success ? { calculationCharts: charts.data } : {}), @@ -153,6 +156,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor markdown, reportId: typeof view.id === "string" ? view.id : "", createdAt: typeof view.createdAt === "string" ? view.createdAt : "", + ...(subjectDisplayName ? { subjectDisplayName } : {}), }; } return { phase: "legacy-unavailable" }; @@ -195,6 +199,13 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function readSubjectDisplayName(json: unknown): string | undefined { + if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined; + const trimmed = json.subjectDisplayName.trim(); + if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined; + return trimmed; +} + /** * The English edition is trusted only in the shape the route promises and only * when it holds no Han character; anything else reads as unavailable, and the @@ -575,7 +586,14 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor />
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */} - + {state.calculationCharts && } {factTables && }
diff --git a/frontend/src/components/personal-report/report-focus-card.tsx b/frontend/src/components/personal-report/report-focus-card.tsx new file mode 100644 index 00000000..d7444f0b --- /dev/null +++ b/frontend/src/components/personal-report/report-focus-card.tsx @@ -0,0 +1,70 @@ +import { + periodRange, + planetLabel, + type ReportFocusBlock, +} from "@/lib/report-focus-block"; +import type { ReportLanguage } from "@/lib/report-language"; + +const LEVEL = { + md: { zh: "主运", en: "Major" }, + ad: { zh: "子运", en: "Sub" }, + pd: { zh: "次子运", en: "Sub-sub" }, +} as const; + +const DOMAIN = { + career: { zh: "事业", en: "Career" }, + wealth: { zh: "财富", en: "Wealth" }, + relationship: { zh: "婚恋", en: "Relationship" }, +} as const; + +export function ReportFocusCard({ + block, + language, +}: { + block: ReportFocusBlock; + language: ReportLanguage; +}) { + const en = language === "en"; + return ( +
+

{en ? "Current period" : "现在走的大运"}

+
+ {block.periods.map((period) => { + const range = periodRange(period.start, period.end); + return ( +

+ {LEVEL[period.level][language]} · {planetLabel(period.lord, language)} + {range ? {range} : null} +

+ ); + })} +
+
+ {block.domains.map((domain) => { + const name = planetLabel(domain.lord, language); + const placed = domain.placed === null + ? (en ? "house not listed" : "落宫未列出") + : (en ? `house ${domain.placed}` : `第${domain.placed}宫`); + const facts = en + ? `House ${domain.house} lord · ${name} · ${placed}` + : domain.placed === null + ? `第${domain.house}宫主 · ${name} · ${placed}` + : `第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`; + return ( +
+

+ {DOMAIN[domain.key][language]} + {facts} +

+

+ {domain.dignity} + {domain.role} +

+
+ ); + })} +
+

{block.caveat}

+
+ ); +} diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts index 7b4ac69b..478444a0 100644 --- a/frontend/src/lib/personal-report-longform-download.ts +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -1,7 +1,8 @@ import { stripReportChartBlocks } from "./report-chart-block"; +import { renderReportFocusFences } from "./report-focus-block"; import { projectOrdinaryReportMarkdown } from "./report-public-projection"; /** Historical ordinary export contract, retained as the full-body byte regression lock. */ export function ordinaryReportDownloadMarkdown(markdown: string): string { - return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)); + return renderReportFocusFences(stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown))); } diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index aef99fb2..651efd77 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -147,6 +147,18 @@ export function reportListTimestamp(value: unknown): string { return ""; } +/** Display name for the signed-in reader. Birth fields stay off this response. */ +export function readOwnedSubjectDisplayName(document: unknown): string | undefined { + if (!document || typeof document !== "object") return undefined; + const subject = (document as { subject?: unknown }).subject; + if (!subject || typeof subject !== "object") return undefined; + const name = (subject as { displayName?: unknown }).displayName; + if (typeof name !== "string") return undefined; + const trimmed = name.trim(); + if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined; + return trimmed; +} + export function reportView( row: PersonalReportRecord, job?: PersonalReportJobRecord | null, @@ -753,6 +765,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise renderReportFocusFences(stripReportChartBlocks(text), language); + if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", publishBody(outline.sourcePreamble)); for (const section of outline.sections) { - add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown)); + add(`body:${section.id}`, "body", section.title, publishBody(section.sourceMarkdown)); } const chartIds = fencedReportChartIds(projected); for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) { diff --git a/frontend/src/lib/report-focus-block.ts b/frontend/src/lib/report-focus-block.ts new file mode 100644 index 00000000..7ceb15ee --- /dev/null +++ b/frontend/src/lib/report-focus-block.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; + +import type { ReportLanguage } from "./report-language"; + +const PLANET_ZH: Record = { + Sun: "太阳", + Moon: "月亮", + Mars: "火星", + Mercury: "水星", + Jupiter: "木星", + Venus: "金星", + Saturn: "土星", + Rahu: "北交点", + Ketu: "南交点", +}; + +const LEVEL_LABEL = { + md: { zh: "主运", en: "Major" }, + ad: { zh: "子运", en: "Sub" }, + pd: { zh: "次子运", en: "Sub-sub" }, +} as const; + +const DOMAIN_LABEL = { + career: { zh: "事业", en: "Career" }, + wealth: { zh: "财富", en: "Wealth" }, + relationship: { zh: "婚恋", en: "Relationship" }, +} as const; + +const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(); +const lord = z.string().min(1).max(40).nullable(); + +export const reportFocusBlockSchema = z.strictObject({ + version: z.literal(1), + periods: z.tuple([ + z.strictObject({ level: z.literal("md"), lord, start: day, end: day }), + z.strictObject({ level: z.literal("ad"), lord, start: day, end: day }), + z.strictObject({ level: z.literal("pd"), lord, start: day, end: day }), + ]), + domains: z.tuple([ + z.strictObject({ + key: z.literal("career"), + house: z.literal(10), + lord, + placed: z.number().int().min(1).max(12).nullable(), + dignity: z.string().min(1).max(80), + role: z.string().min(1).max(80), + }), + z.strictObject({ + key: z.literal("wealth"), + house: z.literal(2), + lord, + placed: z.number().int().min(1).max(12).nullable(), + dignity: z.string().min(1).max(80), + role: z.string().min(1).max(80), + }), + z.strictObject({ + key: z.literal("relationship"), + house: z.literal(7), + lord, + placed: z.number().int().min(1).max(12).nullable(), + dignity: z.string().min(1).max(80), + role: z.string().min(1).max(80), + }), + ]), + caveat: z.string().min(1).max(240), +}); + +export type ReportFocusBlock = z.infer; + +export function parseReportFocusBlock(source: string): ReportFocusBlock | null { + try { + return reportFocusBlockSchema.parse(JSON.parse(source)); + } catch { + return null; + } +} + +export function planetLabel(name: string | null, language: ReportLanguage): string { + if (!name) return language === "en" ? "not listed" : "未列出"; + if (language === "zh") return PLANET_ZH[name] ?? name; + return name; +} + +function monthLabel(iso: string | null): string | null { + if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null; + return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`; +} + +export function periodRange(start: string | null, end: string | null): string | null { + const from = monthLabel(start); + const to = monthLabel(end); + if (from && to) return `${from} – ${to}`; + return from ?? to; +} + +export function focusBlockText(block: ReportFocusBlock, language: ReportLanguage = "zh"): string { + const kicker = language === "en" ? "Current period" : "现在走的大运"; + const periods = block.periods.map((period) => { + const label = LEVEL_LABEL[period.level][language]; + const range = periodRange(period.start, period.end); + const name = planetLabel(period.lord, language); + return range ? `${label} ${name} ${range}` : `${label} ${name}`; + }); + const domains = block.domains.map((domain) => { + const label = DOMAIN_LABEL[domain.key][language]; + const name = planetLabel(domain.lord, language); + const placed = domain.placed === null + ? (language === "en" ? "house not listed" : "落宫未列出") + : (language === "en" ? `house ${domain.placed}` : `第${domain.placed}宫`); + const where = language === "en" + ? `${label}: House ${domain.house} lord · ${name} · ${placed}` + : domain.placed === null + ? `${label}:第${domain.house}宫主 · ${name} · ${placed}` + : `${label}:第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`; + return `${where}(${domain.dignity};${domain.role})`; + }); + return [kicker, ...periods, ...domains, block.caveat].join("\n"); +} + +const FOCUS_FENCE_RE = /```jyotish-focus\r?\n([\s\S]*?)```/g; + +/** Turn the fence into readable lines. A bad fence becomes the same invalid sentence as the page. */ +export function renderReportFocusFences(markdown: string, language: ReportLanguage = "zh"): string { + return markdown.replace(FOCUS_FENCE_RE, (_full, source: string) => { + const block = parseReportFocusBlock(String(source).trim()); + if (!block) return language === "en" ? "Focus data is invalid\n" : "重点数据无效\n"; + return `${focusBlockText(block, language)}\n`; + }); +} + +export function reportTitleByline(input: { + title: string; + subjectName?: string | null; + createdAt?: string | null; + language?: ReportLanguage; +}): string | null { + const language = input.language ?? "zh"; + const name = (input.subjectName ?? "").trim(); + const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec((input.createdAt ?? "").trim()); + const date = dateMatch && validDay(dateMatch[1]) ? dateMatch[1] : ""; + const showName = name.length > 0 && !input.title.includes(name); + if (!showName && !date) return null; + if (language === "en") { + if (showName && date) return `${name} · Generated ${date}`; + if (showName) return name; + return `Generated ${date}`; + } + if (showName && date) return `${name} · 生成于 ${date}`; + if (showName) return name; + return `生成于 ${date}`; +} + +function validDay(iso: string): boolean { + const [year, month, day] = iso.split("-").map(Number); + if (!year || !month || !day) return false; + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + return true; +} diff --git a/frontend/src/lib/report-public-projection.ts b/frontend/src/lib/report-public-projection.ts index 685dc734..1b9a05d4 100644 --- a/frontend/src/lib/report-public-projection.ts +++ b/frontend/src/lib/report-public-projection.ts @@ -417,7 +417,7 @@ function analyzeOrdinaryReportMarkdown(markdown: string, scope: ProjectionScope } if (block.type === "fence") { - if (block.lang === "jyotish-chart" && fenceIsPublic(block.raw)) { + if ((block.lang === "jyotish-chart" || block.lang === "jyotish-focus") && fenceIsPublic(block.raw)) { kept.push(block.raw); continue; } @@ -497,7 +497,7 @@ function projectProseLine(line: string, scope: ProjectionScope = "report"): { ke /** Safety-only boundary for the separately authorized raw attachment, not prose projection. */ export function sanitizeRawAppendixMarkdown(markdown: string): string { return parseBlocks(markdown).map((block) => { - if (block.type === "fence" && block.lang === "jyotish-chart") { + if (block.type === "fence" && (block.lang === "jyotish-chart" || block.lang === "jyotish-focus")) { return fenceIsPublic(block.raw) ? block.raw : ""; } return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => ( diff --git a/frontend/tests/fixtures/report-focus-fictional-day.json b/frontend/tests/fixtures/report-focus-fictional-day.json new file mode 100644 index 00000000..f821293e --- /dev/null +++ b/frontend/tests/fixtures/report-focus-fictional-day.json @@ -0,0 +1,9 @@ +{ + "fixtureProvenance": { + "fictional": true, + "edition": "full_data", + "command": "From tests.test_report_english_edition CASES day, edition full_data, both languages. Fictional chart only." + }, + "zh": "## 重点\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"入庙\",\"role\":\"功能吉星\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"入友\",\"role\":\"功能吉星\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"中性\",\"role\":\"功能中性\"}],\"caveat\":\"这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。\"}\n```", + "en": "## Focus\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"Own Sign\",\"role\":\"functional benefic\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"Friendly Sign\",\"role\":\"functional benefic\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"Neutral\",\"role\":\"functional neutral\"}],\"caveat\":\"This period may bring those house-lord topics forward. It is not a certain event.\"}\n```" +} diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 8bd09117..0470ea2a 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -1165,6 +1165,33 @@ test("core read: returns a legitimate ready document after canonical re-validati assert.ok(response.body.reportDocument); }); +test("core read: owner response includes the display name and not the birth fingerprint", async () => { + const persistence = new MemoryPersistence(); + const row = { + ...seedRecord(), + reportDocument: { + schemaVersion: "report_document.v2", + subject: { displayName: "虚构甲", birthFingerprint: "ab".repeat(16) }, + executiveSummary: { headline: "摘要", summary: "正文" }, + } as unknown as PersonalReportRecord["reportDocument"], + }; + persistence.rows.set(REPORT_ID, row); + const response = 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(response.status, 200); + assert.equal(response.body.subjectDisplayName, "虚构甲"); + assert.equal(JSON.stringify(response.body).includes("birthFingerprint"), false); + assert.equal(JSON.stringify(response.body.reportDocument).includes("虚构甲"), false); +}); + 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()); diff --git a/frontend/tests/report-focus-card.test.ts b/frontend/tests/report-focus-card.test.ts new file mode 100644 index 00000000..040a8213 --- /dev/null +++ b/frontend/tests/report-focus-card.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx"; +import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx"; +import { buildReportExportBlocks, joinExportBlocks } from "../src/lib/report-export-blocks.ts"; +import { parseReportFocusBlock } from "../src/lib/report-focus-block.ts"; +import { projectOrdinaryReportDocument, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection.ts"; + +Object.assign(globalThis, { React }); + +const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-focus-fictional-day.json", import.meta.url), "utf8")) as { + fixtureProvenance: { fictional: boolean; command: string; edition: string }; + zh: string; + en: string; +}; + +const OLD_MARKDOWN = `# 印度占星星盘报告 + +重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。 + +## 重点 + +- 事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。 + +## 出生资料与图盘 + +旧正文仍在。 +`; + +function markup(markdown: string, props: { subjectName?: string; createdAt?: string; language?: "zh" | "en" } = {}) { + return renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown, drawnThrough: 0, ...props })); +} + +test("fictional day-chart golden renders the focus card without MD labels", () => { + assert.equal(fixture.fixtureProvenance.fictional, true); + assert.match(fixture.fixtureProvenance.command, /full_data/); + assert.equal(fixture.fixtureProvenance.edition, "full_data"); + const html = markup(fixture.zh); + assert.match(html, /现在走的大运/); + assert.match(html, /主运/); + assert.match(html, /子运/); + assert.match(html, /次子运/); + assert.match(html, /事业/); + assert.match(html, /财富/); + assert.match(html, /婚恋/); + assert.match(html, /这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。/); + assert.doesNotMatch(html, /\bMD\b|\bAD\b|\bPD\b/); + assert.doesNotMatch(html, /\{"version"/); + const english = markup(fixture.en, { language: "en" }); + assert.match(english, /Current period/); + assert.match(english, /Career/); + assert.match(english, /This period may bring those house-lord topics forward/); + assert.equal(parseReportFocusBlock("{"), null); + assert.match(markup("## 重点\n\n```jyotish-focus\n{not-json}\n```\n"), /重点数据无效/); + assert.match(markup("## Focus\n\n```jyotish-focus\n{not-json}\n```\n", { language: "en" }), /Focus data is invalid/); +}); + +test("export writes the focus as words and the public projection keeps the fence", () => { + const blocks = buildReportExportBlocks({ markdown: fixture.zh }); + const text = joinExportBlocks(blocks); + assert.doesNotMatch(text, /\{"version"/); + assert.match(text, /现在走的大运/); + assert.match(text, /这段时间可能突出这些宫主所守护的主题/); + const projected = projectOrdinaryReportMarkdown(fixture.zh); + assert.match(projected, /```jyotish-focus/); + const document = projectOrdinaryReportDocument({ + schemaVersion: "report_document.v2", + subject: { displayName: "虚构甲" }, + executiveSummary: { headline: "摘要", summary: "正文" }, + }); + assert.equal(JSON.stringify(document).includes("虚构甲"), false); +}); + +test("old prose without a fence is unchanged", () => { + const html = markup(OLD_MARKDOWN); + assert.match(html, /重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。/); + assert.match(html, /事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。/); + assert.doesNotMatch(html, /重点数据无效|personal-report-focus-card|现在走的大运/); +}); + +test("title byline covers a name, no name, and a title that already includes the name", () => { + const createdAt = "2026-10-13T08:00:00.000Z"; + const named = markup("# 印度占星星盘报告\n", { subjectName: "虚构甲", createdAt }); + assert.match(named, /虚构甲 · 生成于 2026-10-13/); + const unnamed = markup("# 印度占星星盘报告\n", { createdAt }); + assert.match(unnamed, /生成于 2026-10-13/); + assert.doesNotMatch(unnamed, /虚构甲/); + const already = markup("# 虚构甲的印度占星星盘报告\n", { subjectName: "虚构甲", createdAt }); + assert.match(already, /生成于 2026-10-13/); + assert.doesNotMatch(already, /虚构甲 · 生成于/); + const english = markup("# Vedic Astrology Chart Report\n", { subjectName: "Fictional", createdAt, language: "en" }); + assert.match(english, /Fictional · Generated 2026-10-13/); +}); + +test("the signed-in envelope carries the display name onto the reader", () => { + const ready = classifyReportEnvelope(200, { + report: { + id: "11111111-1111-1111-1111-111111111111", + status: "ready", + failureCode: null, + createdAt: "2026-10-13T00:00:00Z", + }, + longformMarkdown: "# 印度占星星盘报告\n", + subjectDisplayName: "虚构甲", + }); + assert.equal(ready.phase, "markdown-ready"); + if (ready.phase === "markdown-ready") assert.equal(ready.subjectDisplayName, "虚构甲"); +}); diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index d5283154..2cc8b834 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -91,7 +91,12 @@ def _load_bhava_rows(): return bhava_house_rows -def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星星盘报告") -> str: +def _sanitize_pl9_ai_density_markdown( + markdown: str, + title: str = "印度占星星盘报告", + *, + opening_summary: bool = True, +) -> str: """Keep the parity volume's facts while removing internal-only labels.""" try: @@ -111,6 +116,8 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星 from pl9_language_terms import translate_zh_glued_phrases except ModuleNotFoundError: # pragma: no cover - package import compatibility from scripts.pl9_language_terms import translate_zh_glued_phrases + hold_focus_fences, restore_focus_fences = _focus_fence_tools() + markdown, focus_slots = hold_focus_fences(markdown) # Whole phrases and glued planet+dignity tokens before shorter word swaps. # Chapters appended after the parity strip still pass through here. text = translate_zh_glued_phrases(_sanitize_pl9_public_markdown(markdown)) @@ -515,10 +522,12 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星 continue first_body_line = line break - has_opening_summary = bool(first_body_line) and any( - marker in first_body_line for marker in ("摘要:", "结论:", "重点:") + has_opening_summary = _opening_focus_authored(text) or ( + bool(first_body_line) and any( + marker in first_body_line for marker in ("摘要:", "结论:", "重点:") + ) ) - if text.startswith(f"# {title}") and not has_opening_summary: + if opening_summary and text.startswith(f"# {title}") and not has_opening_summary: summary = "\n".join( [ f"# {title}", @@ -532,7 +541,7 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星 ] ) text = re.sub(r"^#\s+.*$", summary, text, count=1, flags=re.MULTILINE) - return strip_han_plural_s(text) + "\n" + return restore_focus_fences(strip_han_plural_s(text) + "\n", focus_slots) @@ -887,7 +896,8 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr except ModuleNotFoundError: # pragma: no cover - package import compatibility from scripts.pl9_language_terms import normalize_en_markdown_terms - text = markdown + hold_focus_fences, restore_focus_fences = _focus_fence_tools() + text, focus_slots = hold_focus_fences(markdown) replacements = { "# PL9 对标主册": f"# {title}", "PL9 对标主册": title, @@ -1266,7 +1276,7 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr # 防御性幂等:折叠残留的 "condition condition"(确保 sanitize 可安全重复执行)。 text = re.sub(r"\b(condition)(?:\s+condition)+\b", r"\1", text, flags=re.IGNORECASE) text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n" - return text + return restore_focus_fences(text, focus_slots) @@ -1279,7 +1289,10 @@ _ZH_FUNCTION_WORD_RE = re.compile( r"(? dict[str, int]: @@ -2230,14 +2243,36 @@ def _append_native_rashi_dashas(markdown: str, packet: dict) -> str: return markdown.rstrip() + "\n\n" + chapter +def _focus_fence_tools(): + try: + from report_focus_block import hold_focus_fences, restore_focus_fences + except ModuleNotFoundError: # pragma: no cover - package import compatibility + from scripts.report_focus_block import hold_focus_fences, restore_focus_fences + return hold_focus_fences, restore_focus_fences + + +def _opening_focus_authored(text: str) -> bool: + """True when the title is already followed by the focus heading or fence. + + The fixed checklist must not come back just because the old one-line + 「重点:」 summary was removed. + """ + + head = "\n".join((text or "").splitlines()[:16]) + if re.search(r"(?m)^## (?:重点|Focus)\s*$", head): + return True + return "```jyotish-focus" in head or "JYOTISHFOCUSHOLD" in head + + def _insert_opening_focus(markdown: str, focus: str) -> str: """Put the current-period focus directly under the title.""" - first = next((line.strip() for line in focus.splitlines() if line.strip()), "") - if not first or first in markdown: + if "```jyotish-focus" not in (focus or ""): return markdown - match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE) + if "```jyotish-focus" in (markdown or ""): + return markdown + match = re.search(r"^#\s+.+$", markdown or "", flags=re.MULTILINE) if not match: - return focus.rstrip() + "\n\n" + markdown + return focus.rstrip() + "\n\n" + (markdown or "") return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :] @@ -2286,6 +2321,7 @@ def render_full_data_markdown(packet: dict) -> str: zh_structured_markdown = _sanitize_pl9_ai_density_markdown( _render_pl9_ai_density_raw_data_markdown_en(packet), _pl9_public_report_title(packet), + opening_summary=False, ) zh_structured_appendix = re.sub( r"^#\s+.*?(?:\n{2,}|\n)", diff --git a/scripts/pl9_interpretation_packs.py b/scripts/pl9_interpretation_packs.py index d40ba05a..eb719c4b 100644 --- a/scripts/pl9_interpretation_packs.py +++ b/scripts/pl9_interpretation_packs.py @@ -1393,61 +1393,92 @@ def missing_field_count(packet: dict) -> int: return len(fields) +def _iso_day(value: Any) -> str | None: + text = _date_text(value) + return text if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text) else None + + +def _current_period_rows(packet: dict) -> dict[str, dict]: + """Current MD/AD/PD rows already stored on the dasha timeline. No new dates.""" + + dasha = _dict(_timing(packet).get("dasha")) + current = _dict(dasha.get("current_dasha")) + md_row = current if current.get("lord") else {} + if not md_row: + for period in _list(dasha.get("timeline")): + if isinstance(period, dict) and period.get("is_current"): + md_row = period + break + ad_row: dict = {} + pd_row: dict = {} + for row in _list(md_row.get("antardasha_timeline")): + if isinstance(row, dict) and row.get("is_current"): + ad_row = row + for child in _list(row.get("pratyantar_dasha_timeline")): + if isinstance(child, dict) and child.get("is_current"): + pd_row = child + break + break + return {"md": md_row, "ad": ad_row, "pd": pd_row} + + +def _focus_fence(payload: dict) -> str: + try: + from report_focus_block import build_focus_fence + except ModuleNotFoundError: # pragma: no cover - package import compatibility + from scripts.report_focus_block import build_focus_fence + return build_focus_fence(payload) + + def opening_focus_block(packet: dict) -> str: - """Current MD/AD/PD plus career, wealth, and relationship house lords.""" + """Current MD/AD/PD plus career, wealth, and relationship house lords. + + The reader draws the card from the fence. This function does not compute + a new dasha or house lord; missing dates stay null. + """ language = _lang(packet) - lords = _current_lords(packet) houses = _houses(packet) planets = _planets(packet) classification = _functional_classification(packet) - - def lord_line(number: int, topic_zh: str, topic_en: str) -> str: + rows = _current_period_rows(packet) + periods = [] + for level in ("md", "ad", "pd"): + row = rows[level] + lord = str(row.get("lord") or "").strip() + periods.append({ + "level": level, + "lord": lord or None, + "start": _iso_day(row.get("start")), + "end": _iso_day(row.get("end")), + }) + domains = [] + for key, number in (("career", 10), ("wealth", 2), ("relationship", 7)): house = _house_row(houses, number) - lord = str(house.get("lord") or "") + lord = str(house.get("lord") or "").strip() row = planets.get(lord) if isinstance(planets.get(lord), dict) else {} placed = row.get("house") - dignity = _dignity(row.get("status") or row.get("dignity"), language) - role = _role(classification.get(lord), language) - if language == "zh": - return ( - f"- {topic_zh}第{number}宫主 {_planet(lord, language)} 落在第{placed if isinstance(placed, int) else '未列出'}宫," - f"尊贵为{dignity},功能吉凶为{role}。" - ) - return ( - f"- {topic_en} house {number} lord {lord or 'not listed'} is in house " - f"{placed if isinstance(placed, int) else 'not listed'}, dignity {dignity}, functional nature {role}." - ) - + domains.append({ + "key": key, + "house": number, + "lord": lord or None, + "placed": placed if isinstance(placed, int) else None, + "dignity": _dignity(row.get("status") or row.get("dignity"), language), + "role": _role(classification.get(lord), language), + }) if language == "zh": - lines = [ - "重点:当前主运 MD {md},子运 AD {ad},次子运 PD {pd}。".format( - md=_planet(lords["md"], language), - ad=_planet(lords["ad"], language), - pd=_planet(lords["pd"], language), - ), - "", - "## 重点", - lord_line(10, "事业", "Career"), - lord_line(2, "财富", "Resources"), - lord_line(7, "婚恋", "Relationship"), - "- 这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。", - ] + caveat = "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。" + heading = "## 重点" else: - lines = [ - "Focus: current Maha Dasha MD {md}, Antar Dasha AD {ad}, Pratyantar Dasha PD {pd}.".format( - md=lords["md"] or "not listed", - ad=lords["ad"] or "not listed", - pd=lords["pd"] or "not listed", - ), - "", - "## Focus", - lord_line(10, "事业", "Career"), - lord_line(2, "财富", "Resources"), - lord_line(7, "婚恋", "Relationship"), - "- This period may bring those house-lord topics forward. It is not a certain event.", - ] - return "\n".join(lines) + caveat = "This period may bring those house-lord topics forward. It is not a certain event." + heading = "## Focus" + fence = _focus_fence({ + "version": 1, + "periods": periods, + "domains": domains, + "caveat": caveat, + }) + return f"{heading}\n\n{fence}" def _strength_snapshot(packet: dict) -> dict: diff --git a/scripts/report_focus_block.py b/scripts/report_focus_block.py new file mode 100644 index 00000000..65337be2 --- /dev/null +++ b/scripts/report_focus_block.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Structured opening-focus fence for the full-data report. + +The engine writes one ``jyotish-focus`` JSON fence. The report page renders +that fence; it does not recompute dasha lords or house lords. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +FOCUS_FENCE_RE = re.compile(r"```jyotish-focus\n(.*?)\n```", re.DOTALL) +_FOCUS_BLOCK_RE = re.compile(r"```jyotish-focus\n.*?\n```", re.DOTALL) +_HOLD_TOKEN = "JYOTISHFOCUSHOLD{index}END" + + +def build_focus_fence(payload: dict[str, Any]) -> str: + """Return a one-line JSON fence. Values are already public words.""" + + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return f"```jyotish-focus\n{body}\n```" + + +def parse_focus_fence(markdown: str) -> dict[str, Any] | None: + match = FOCUS_FENCE_RE.search(markdown or "") + if not match: + return None + try: + value = json.loads(match.group(1)) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + + +def hold_focus_fences(markdown: str) -> tuple[str, list[str]]: + """Lift fences out of the glossary pass so planet words stay valid JSON.""" + + slots: list[str] = [] + + def repl(match: re.Match[str]) -> str: + slots.append(match.group(0)) + return _HOLD_TOKEN.format(index=len(slots) - 1) + + return _FOCUS_BLOCK_RE.sub(repl, markdown or ""), slots + + +def restore_focus_fences(markdown: str, slots: list[str]) -> str: + text = markdown + for index, slot in enumerate(slots): + text = text.replace(_HOLD_TOKEN.format(index=index), slot) + return text diff --git a/tests/test_report_full_data_fix.py b/tests/test_report_full_data_fix.py index b6fa49fd..b1a174a6 100644 --- a/tests/test_report_full_data_fix.py +++ b/tests/test_report_full_data_fix.py @@ -8,15 +8,18 @@ calculate a new dasha or import PyJHora. from __future__ import annotations import copy +import json import re from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown from scripts.pl9_interpretation_packs import ( + _current_lords, attach_full_data_interpretation, missing_field_count, opening_focus_block, section_coverage, ) +from scripts.report_focus_block import FOCUS_FENCE_RE, parse_focus_fence from scripts.pl9_reader_english import HAN FORBIDDEN_ZH = ( @@ -170,9 +173,15 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None: packet = _packet("zh") attach_full_data_interpretation(packet) focus = opening_focus_block(packet) - assert focus.startswith("重点:") - assert "MD" in focus and "AD" in focus and "PD" in focus - assert "第10宫" in focus and "第2宫" in focus and "第7宫" in focus + # 原值:focus.startswith("重点:"),并且正文含 MD / AD / PD。 + # 新值:开篇是「## 重点」加一个 jyotish-focus 围栏,不再写那一行行话。 + # 原因:产品选择把开篇做成摘要卡,围栏交给阅读页画,不在正文里堆 MD/AD/PD。 + assert focus.startswith("## 重点") + assert "重点:当前主运" not in focus + assert "MD " not in focus and "AD " not in focus and "PD " not in focus + payload = parse_focus_fence(focus) + assert payload is not None + assert [row["house"] for row in payload["domains"]] == [10, 2, 7] text = render_full_data_markdown(packet) for heading in ("大运解释", "行星、星宿与宫位解释", "宫主逐宫解释", "Dosha 逐项事实与解释", "瑜伽解释"): assert heading in text @@ -194,7 +203,12 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None: def test_english_focus_and_new_sections_have_no_han() -> None: packet = _packet("en") text = render_full_data_markdown(packet) - assert "Focus: current Maha Dasha MD" in text + # 原值:正文含 "Focus: current Maha Dasha MD"。 + # 新值:英文开篇是「## Focus」加 jyotish-focus 围栏,不再写那一行。 + # 原因:与中文摘要卡同一结构;大运章里的 AD/PD 表头仍保留。 + assert "## Focus" in text + assert "```jyotish-focus" in text + assert "Focus: current Maha Dasha MD" not in text assert "AD" in text and "PD" in text assert "Dasha Interpretation" in text assert "House-lord interpretation" in text @@ -209,6 +223,60 @@ def test_english_focus_and_new_sections_have_no_han() -> None: assert leaked == [], leaked[:12] +def _focus_dates(packet: dict, level: str) -> tuple[str | None, str | None]: + dasha = packet["worksheets"]["timing_and_predictive_systems"]["dasha"] + current = dasha["current_dasha"] + if level == "md": + return current.get("start"), current.get("end") + for row in current["antardasha_timeline"]: + if not row.get("is_current"): + continue + if level == "ad": + return row.get("start"), row.get("end") + for child in row["pratyantar_dasha_timeline"]: + if child.get("is_current"): + return child.get("start"), child.get("end") + return None, None + + +def test_opening_focus_fence_matches_current_lords_and_skips_the_fixed_list() -> None: + for language in ("zh", "en"): + packet = _packet(language) + lords = _current_lords(packet) + text = render_full_data_markdown(packet) + fences = FOCUS_FENCE_RE.findall(text.split("## 结构化资料附录", 1)[0].split("## Structured Data Appendix", 1)[0]) + assert len(fences) == 1, language + payload = json.loads(fences[0]) + assert payload["version"] == 1 + assert [row["level"] for row in payload["periods"]] == ["md", "ad", "pd"] + assert [row["lord"] for row in payload["periods"]] == [lords["md"], lords["ad"], lords["pd"]] + for row in payload["periods"]: + start, end = _focus_dates(packet, row["level"]) + assert row["start"] == start + assert row["end"] == end + assert [row["key"] for row in payload["domains"]] == ["career", "wealth", "relationship"] + assert [row["house"] for row in payload["domains"]] == [10, 2, 7] + opening = text.split("## 出生资料", 1)[0].split("## Birth Data", 1)[0] + assert "重点:当前主运" not in opening + assert "先核对出生资料、图盘" not in text + if language == "zh": + assert "MD " not in opening and "AD " not in opening and "PD " not in opening + assert "功能吉凶为功能吉星" not in opening + assert payload["caveat"] == "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。" + else: + assert "Focus: current Maha Dasha" not in opening + + +def test_opening_focus_keeps_null_when_a_period_has_no_date() -> None: + packet = _packet("zh") + packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["current_dasha"]["antardasha_timeline"][0]["end"] = "" + payload = parse_focus_fence(render_full_data_markdown(packet)) + assert payload is not None + ad = next(row for row in payload["periods"] if row["level"] == "ad") + assert ad["start"] == "2001-01-01" + assert ad["end"] is None + + def test_yoga_effect_does_not_assert_lifespan() -> None: # 原值:作用列写「长寿;健康良好」/ Longevity。 # 新值:作用列改成体质与恢复力的参考句。名称「长寿格局」和 Ayur Yoga (Strong Longevity) 可以留下。