Compare commits

...
6 Commits
Author SHA1 Message Date
jesse-ux a3a11c3edf docs(report): record #191 implementation and acceptance evidence
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m57s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m40s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-12 11:52:19 +08:00
jesse-ux 2523dcdc64 test(report): verify Mudda intervals in both report editions 2026-11-12 11:49:45 +08:00
jesse-ux c4d0b379b7 Merge origin/staging into #191 delivery branch 2026-11-12 11:43:00 +08:00
jesse-ux 54b75988dd fix(report): show native Mudda intervals and export saved readings 2026-11-12 11:42:59 +08:00
jesse-ux d905909696 docs(report): record Mudda acceptance scope question 2026-11-12 10:50:29 +08:00
ClaudeandClaude Opus 5.5 b9ce6797e4 docs(task): brief for report annual layers and .md export with reading (#191, BUG-1844..1845, 2026-11-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 10:20:35 +08:00
17 changed files with 686 additions and 22 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-11-12 — 个人报告的 Mudda 时间线与 Markdown 导出
- Mudda 表分别列出原始时间线、名义年和报告覆盖区间,期间按整秒显示。
- 中文 Markdown 下载在摘要后收录已保存的「解读」。
- 本轮沿用当前 Skill 版本。
## 2026-11-07 — 占星师第二次裁定:Drig 用 Narayana 年数、Ashtottari 只看罗睺条件、Sthira 两种方法分开保存
- Drig 每个星座的年数改为与本站 Narayana 同一个函数(双主星择强、入旺落陷调整都算),依据 PVR 21.2(5);Sri Aurobindo 的 Scorpio Drig 1906–1913 作为回归案例(BUG-1678)。
+28
View File
@@ -24931,3 +24931,31 @@
- 关联记录:#198、#192、BUG-1461、BUG-1706、BUG-1843。
- 复发自:无。
- 修复版本:`codex/reading-facts-internal-refs-20261112`。
## BUG-1844 | 个人报告 Mudda 表缺少年盘三个区间
- 状态:fixed-pending-verify(待独立验收)
- 首次发现 / 最近更新:2026-11-11 / 2026-11-12
- 影响面:个人报告的年盘 Mudda 表、期间说明和附录。
- 现象:Mudda 表未显示原始时间线、名义年和覆盖区间,部分日期带小数秒。
- 触发条件:生成包含年盘 Mudda 的个人报告。
- 根因:报告投影只取期间行,未读取已由引擎透传的三个区间。其他体系也会按自身数据精度显示日期。
- 修复:Mudda 展示区分三个区间。期间表使用覆盖区间的裁剪视图,时间按原有时区舍入到整秒。
- 验证:公开盘中英文报告均可见三个区间。Mudda 边界定向测试通过,全文扫描定位到其他体系仍有小数秒。
- 防复发:逐项核对报告区间、原始边界、覆盖视图说明和半秒舍入。
- 关联记录:BUG-1722、#191。
- 修复版本:`codex/report-annual-md-export-20261111`。
## BUG-1845 | 个人报告 Markdown 导出缺少已保存的解读
- 状态:fixed-pending-verify(待独立验收)
- 首次发现 / 最近更新:2026-11-11 / 2026-11-12
- 影响面:个人报告导出和自选正文列表。
- 现象:中文页面显示解读,下载的 Markdown 缺少该节。
- 触发条件:导出包含已保存解读的中文个人报告。
- 根因:导出入口只传引擎正文、图盘和核对表,没有传已读取的解读。
- 修复:把页面已读取的解读交给 Markdown 导出,在摘要后列出原节标题、段落和计算依据标签。
- 验证:导出和英文版相关定向测试通过。无解读的真实报告开头与基线的导出块逐项一致。
- 防复发:验证解读只出现一次、正文默认选中、英文版行为及旧报告导出快照。
- 关联记录:BUG-999、BUG-1634、#191。
- 修复版本:`codex/report-annual-md-export-20261111`。
@@ -0,0 +1,23 @@
# #191 年盘 Mudda 与 Markdown 导出进度
## 验收范围
Claude 在 #191 的评论 41768 确认,整秒验收覆盖中英文个人报告的原生 Mudda 展示。公开盘探针在两种语言中各统计到约 20,610 处其他时间体系的小数秒,涉及 Kalachakra、Tithi Ashtottari、Yoga Vimshottari、Yoga Pravesha、Nakshatra Pravesha、Kaala 和 Sudasa 等。它们沿用各自的精度。
## 数据来源和实现
- `worksheets.timing_and_predictive_systems.annual_tajika_pack.mudda_dasha.data` 带有 `raw_periods`、`raw_antardasha_periods`、`nominal_year_days`、`coverage_interval` 和 `periods_view`。后续年度的 `annual_tajika_series.years` 同样透传这些引擎字段。
- Mudda 表列出原始时间线、名义年和报告覆盖区间,采用覆盖视图标签,并在跨过返照时引用原始期间终点。展示时间按原有时区舍入到整秒。表中有子段或计算资料另列余额口径时按实际数据说明。两个公开盘均覆盖跨返照用例。
- 中文 Markdown 导出把已保存的解读放在摘要之后,沿用页面的节标题、段落和「计算依据」标签。整篇和常用正文预设包含解读。回归覆盖没有解读的旧报告、英文版和手动选择。
- 保留 staging 已追加的 BUG-1846,并追加本工单预留的 BUG-1844、BUG-1845。既有断言未修改。
## 验证
- 前端导出与报告文案定向测试 28 项通过,失败及取消均为 0。旧报告的导出块快照来自与当前 staging 一致的前端源码。
- TypeScript 检查通过。ESLint 为 0 错误、62 条既有警告。`npm run build` 通过,`/` 仍为静态。以首页静态 HTML 引用的 39 个 JS/CSS 资产逐个 gzip-9 求和,JS 515,860 B、CSS 90,827 B,与同口径基线相同。
- 独立 PyJHora 附录 worker 在探针中曾超时,报告生成按现有路径回退。本次回归检查的是原生 Mudda 段。
- Python 原生 Mudda 与真实导出夹具复测 4 项全部通过。上一轮年盘覆盖与报告定向组合 11 项通过。隐私检查 62 项通过。
- Windows 本轮也启动了扩展报告测试、Python 快速门和前端全量测试。交接时扩展报告测试与快速门仍在运行。前端全量已出现两项失败,完整退出结果待确认。在与当前 staging 前端代码一致的检出中,两项均复现:
- `admin-operation-reauth-removal.test.ts` 的 operation-level admin email reauthentication 用例,Windows 路径重复带盘符,返回 ENOENT。
- `consult-evidence-lookup-20260927.test.ts` 的 answer clock 用例,返回 `empty_answer`,断言预期 `answer_truncated`。
@@ -0,0 +1,187 @@
# TASK:个人报告的年盘三层、Mudda 表整理,以及 .md 导出带上「解读」(R8 + 导出,2026-11-11)
- Gitea:#191
- 分支:`codex/report-annual-md-export-20261111`(基于 staging `380bb883`)
- Bug 编号:BUG-1844(R8:年盘三层与 Mudda 表)、BUG-1845(.md 导出缺「解读」)。1836–1845 由 jyotisha-7e 预留;开工时核对 `docs/BUG_HISTORY.md` 末尾。
- 负责验收:Claude(jyotisha-2a)
- 前置:本批第 1–3 单已合入 staging。本单沿用真实报告夹具 `tests/public_chart_reports.py`。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
- 产品负责人 2026-11-11 决定 3:个人报告导出**只有 .md**,而且要包含「解读」。**永远不加 PDF**,原因是服务器负担太大。
- BUG-1722 与占星师第五轮裁定 2:年盘 Mudda 有三个区间,必须分开写。说明在 `scripts/tajika_mudda.py` 顶部 docstring:
- (a) 原始完整时间线:`raw_periods` / `raw_antardasha_periods`,不截断,最后一组会越过下一次真实返照;
- (b) 名义年:`nominal_year_days` 及其说明 `NOMINAL_YEAR_DAYS_NOTE`;
- (c) 报告覆盖区间:`coverage_interval`,本次真实返照到下一次真实返照。
`periods` / `antardasha_periods` 只是裁到 (c) 的视图,标签在 `periods_view.label`。越过返照的那一段用她的原话:`scripts/consultation_native_layers.py` 的 `MUDDA_CROSSING_TEXT`,同一句也在 `scripts/annual_service/package.py` 的 `CROSSING_TEXT`。年报(`scripts/annual_service/package.py` 约 :1055–1105)已经分开这三层,个人报告还没有。
## 现状(staging 实测,行号以符号为准)
### R8(BUG-1844)
真实报告(Jobs)的年盘部分:
- `## Mudda 大运` 标题下面是空的。
- 来源:`scripts/pl9_full_data_export.py` 约 :2100–2108,`dasha_sequence` 为空时仍输出标题和空表。
- `## 年度 Mudda 大运行` 的时间带微秒,例如 `2026-02-27 14:28:42.675926`。
- 行由 `scripts/pl9_interpretation_packs.py` 约 :1255–1290 组装,`annual_native/annual_expansion.py` 约 :118 也会填。
- 表由 `pl9_full_data_export.py` 约 :2113 和 `scripts/pl9_reader_export.py` 约 :2540–2577 渲染。
- 表里列了 antardasha 子段,后面却写「这张表没有日级行,也没有第二套余额口径。」(`pl9_reader_export.py` 约 :2576)。这句与表格内容矛盾。
- 三个区间都没有出现:没有原始完整时间线终点、没有名义年长、没有报告覆盖区间,也没有越过返照的那一句。
### 导出(BUG-1845)
- 页面上「解读」显示在开头摘要之后、引擎章节之前:`frontend/src/components/personal-report/personal-report-markdown-view.tsx` 约 :395–481,`ReportReadingSection`。
- 导出不带它:
- `personal-report-page.tsx` 约 :650 给 `ReportActions` 传了 `markdown` / `charts` / `factTables`,没传 `reading`;
- `frontend/src/lib/report-export-blocks.ts` 的 `buildReportExportBlocks` 没有「解读」块。
- 导出抽屉是 `frontend/src/components/personal-report/report-export-drawer.tsx`,下载用 `consultation-report-export.ts` 的 `downloadMarkdownReport`,现在只有 .md。
## 要改的文件
- R8:
- `scripts/pl9_interpretation_packs.py`:只改 Mudda 行组装(约 :1255–1290)。
- `scripts/pl9_full_data_export.py`:只改 Mudda 标题和 Mudda 表那一段。
- `scripts/pl9_reader_export.py`:只改 Mudda 段(约 :2540–2577)。
- `scripts/annual_native/annual_expansion.py`:确需时只改 Mudda 行。
- 导出:
- `frontend/src/lib/report-export-blocks.ts`
- `frontend/src/components/personal-report/report-actions.tsx`
- `frontend/src/components/personal-report/personal-report-page.tsx`:只加把 `reading` 传给 `ReportActions` 这一处。
- `frontend/src/components/personal-report/report-export-drawer.tsx`:只在需要给新块排序或分组时改。
- 测试:
- 新建 `tests/test_report_annual_layers_20261111.py`
- 新建 `frontend/tests/report-md-export-reading-20261111.test.ts`
- 新建抓取脚本 `scripts/research/capture_report_export_fixture_20261111.py` 与夹具目录 `frontend/tests/fixtures/report-export-20261111/`(见验收第 5 条)
- `docs/BUG_HISTORY.md`:只在末尾追加两条。
- `docs/tasks/PROGRESS-report-annual-md-export-20261111.md`
## 要做的
### 一、R8:Mudda 三层与表格整理
1. Mudda 段先写一张“区间”小表,三行,值都取引擎原值,不在报告层算:
| 区间 | 起 | 止 | 说明 |
|---|---|---|---|
| 原始完整时间线 | `raw_periods` 首段起点 | 末段终点 | 不截断 |
| 名义年 | | | `nominal_year_days` 天,说明照抄 `NOMINAL_YEAR_DAYS_NOTE` |
| 本报告覆盖区间 | `coverage_interval` 起点 | 终点 | 天数照抄 `days` |
- 下面的大运行与子段行是裁到覆盖区间的视图,表前写 `periods_view.label` 原文。
- 有越过下一次返照的段时,照抄 `MUDDA_CROSSING_TEXT`,`{end}` 填原始终点。从 `consultation_native_layers` import,不另写一份。
- packet 里的个人报告年盘数据没有这些字段时,先查 `scripts/tajika_mudda.py` 的输出是否被中途丢掉,在 PROGRESS 写清。要改冻结文件才能拿到时,停下选需拍板。
2. 时间格式:
- 所有 Mudda 起止时间先四舍五入到整秒,做法同 `annual_service/package.py` 的 `instant()`:加 0.5 秒后去掉微秒;
- 格式与同一报告的标准大运表一致:`YYYY-MM-DDTHH:MM:SS`;
- 时区沿用现在的基准,不改换算。
3. 删掉「这张表没有日级行,也没有第二套余额口径。」这句(中英文两处),换成按数据写的说明:
- 有子段行时写“表中含子段(antardasha)行”;
- packet 里有其他余额口径时(`annual_mudda_profiles` 加的 `moon_in_varshaphala`、`without_dasha_balance`),写出它们的名字和所在位置;
- 没有时写“只有默认余额口径”。
4. `## Mudda 大运` 标题:`dasha_sequence` 为空时不输出这个标题和空表。
### 二、导出带上「解读」(.md)
1. `buildReportExportBlocks` 加可选参数 `reading?: ReportReading | null`。有中文解读时,在 `body:lead`(摘要)之后、第一个引擎章节之前,加一个 `body` 块:
- `id` 为 `body:reading`,标题用 `REPORT_READING_HEADING`;
- 内容:`## 解读`,每节 `### {节标题}`,各段原文逐段输出,段间空一行;
- 末尾一行 `REPORT_READING_BASIS_LABEL`(计算依据),与页面一致;
- 文字与页面上显示的逐字一致,不经 `projectOrdinaryReportMarkdown`,不改写,不加任何说明句。
2. 英文版:页面上英文版不显示解读(`personal-report-markdown-view.tsx` 约 :405),导出也不加。
3. 「解读」块在默认勾选里:常用预设和全选都包含它。
4. `personal-report-page.tsx` 把 `state.reading` 传给 `ReportActions`,`ReportActions` 再传给 `buildReportExportBlocks`。
5. 导出格式仍只有 .md:
- 不新增 PDF、打印成 PDF、docx 或任何服务器端渲染导出;
- 不新增依赖;
- 页面原有的浏览器打印样式不动。
6. 没有解读(旧报告或生成失败)时导出与现在完全相同。
## 不要碰
- `references/**` 一字不改。
- `frontend/src/lib/consult-preset-prompts.ts`,以及聊天文件 `frontend/src/lib/consultation-*.ts`。
- 例外:`frontend/src/lib/consultation-report-export.ts` 只许 import 调用,不改。
- 个人报告解读层,只许 import 类型和常量,不改:
- `frontend/src/lib/personal-report-reading.ts`
- `frontend/src/lib/personal-report-reading-contract.ts`
- `frontend/src/mastra/personal-report-reading.ts`
- `frontend/src/components/personal-report/report-reading-section.tsx`
- `frontend/src/lib/report-public-projection.ts`。
- 冻结或他人负责的计算文件只许 import:
- `scripts/jyotish_engine.py`、`scripts/jyotish_api_server.py`
- `scripts/tajika_mudda.py`、`scripts/solar_return.py`、`scripts/annual_tajika_pack.py`
- `scripts/annual_service/**`(年报)、`scripts/consultation_native_layers.py`
- `scripts/narayana_rath.py`、`scripts/chart_dasha/**`
- 进行中单子的文件:
- #172:`frontend/src/app/api/consult/route.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/lib/consult/turn-card-cache.ts`
- #177、#178:`scripts/ondemand_calc/**`
- #183:`frontend/src/lib/consultation-answer-fact-guard.ts`、`frontend/src/lib/yoga-status-words.ts`
- 前三单已定的部分:清洗函数、瑜伽与两轴状态、审计章、Narayana 与 Shadbala 段落。
## 验收标准
1. **R8 真实报告**(`tests/test_report_annual_layers_20261111.py`,Jobs 与 Obama,`tests/public_chart_reports.py`):
- 中文报告里没有空的 `## Mudda 大运` 标题,即标题下一个非空行不是另一个标题;
- 全文没有 `\d{2}:\d{2}:\d{2}\.\d+` 这种带小数秒的时间;
- 没有「这张表没有日级行」;
- Mudda 段有区间小表,三行的值与 packet 里的引擎原值相等(四舍五入到整秒后);
- 名义年说明逐字等于 `NOMINAL_YEAR_DAYS_NOTE`;
- 视图标签逐字等于 `periods_view.label`。
英文版同样没有小数秒、没有 `Day-level rows and a second balance profile are not in this table.`
2. **R8 越界段**:存在越过返照的段时,句子逐字等于 `MUDDA_CROSSING_TEXT` 填入原始终点后的结果。两张盘都没有这种段时,用合成 packet 测,并在 PROGRESS 写明。
3. **R8 时间舍入**:合成行 `2026-02-27 14:28:42.675926` → `2026-02-27T14:28:43`;`…:42.4` → `…:42`。
4. **导出单元**(`frontend/tests/report-md-export-reading-20261111.test.ts`):
- 有中文解读时 `body:reading` 紧跟在 `body:lead` 后面;
- 内容含每一节标题和每一段原文,逐字相同;
- 在默认勾选里;
- 拼出的下载文本里 `## 解读` 只出现一次;
- 没有解读时,块列表与改动前逐项相等(与改动前的函数输出对比,或用快照);
- 英文版没有解读块。
5. **导出用真实报告**:
- 用抓取脚本从 Jobs 的真实中文报告(同一夹具写法,固定 `today = "2026-11-11"`)截取开头到第二个 `## ` 章节之前的全文,存成 `frontend/tests/fixtures/report-export-20261111/jobs-head-zh.md`;
- 同时存一份 9 节的解读样例 `reading-sample.json`:内容是写死的样例文字,不调模型,不写任何非公开出生资料;
- 第 4 条的测试也要在这份真实开头上跑一遍:`body:lead` 来自真实文字,`body:reading` 紧随其后;
- 抓取脚本可重跑;另写一条 Python 测试,检查夹具与当前渲染一致,写法照 `tests/test_narayana_branches_d_20261109.py::test_fixtures_are_fresh`。
6. **不加 PDF**:测试断言导出抽屉组件源码与 `report-export-blocks.ts` 里没有 `pdf`(不区分大小写)、`jspdf`、`puppeteer`、`print-to-pdf` 等字样;`frontend/package.json` 依赖没有新增。
7. 前三单的测试照样全过:
- `tests/test_report_wording_sanitizer_20261111.py`
- `tests/test_report_status_words_20261111.py`
- `tests/test_report_narayana_shadbala_20261111.py`
- `frontend/tests/report-no-web-wording-20261109.test.ts`
- 现有导出相关前端测试(`frontend/tests/` 下文件名含 `report-export` 的)
8. `docs/BUG_HISTORY.md` 末尾追加 BUG-1844、BUG-1845,格式照最近条目,不写任何出生资料或姓名。BUG-1844 的“关联记录”写 BUG-1722。
## 自测命令(Windows / PowerShell)
- 仓库根目录:`.venv\Scripts\python -m pytest tests/test_report_annual_layers_20261111.py tests/test_report_wording_sanitizer_20261111.py tests/test_report_status_words_20261111.py tests/test_report_narayana_shadbala_20261111.py tests/test_annual_coverage_bug1722.py tests/test_report_full_data_fix.py tests/test_report_english_edition.py -q`
- 仓库根目录:`frontend\node_modules\.bin\tsc --noEmit -p frontend`
- 在 `frontend` 下:`npm run lint`
- 在 `frontend` 下:`node node_modules/tsx/dist/cli.mjs --test tests/report-md-export-reading-20261111.test.ts tests/report-no-web-wording-20261109.test.ts`,以及文件名含 `report-export` 的现有测试
- 再跑一次 `.venv\Scripts\python -m pytest tests -q --timeout 600`(或你能跑的最大范围),把失败名单写进 PROGRESS,并注明哪些是 staging 上原本就红的。
前端全量测试、Python 快速门和 eslint 全量由 Claude 在验收时跑。
## 进度记录
写在 `docs/tasks/PROGRESS-report-annual-md-export-20261111.md`,内容包括:
- 做了什么;
- 跑了哪些命令,通过 / 失败数;
- 改过的既有断言(原值/新值/原因);
- 年盘三层字段在个人报告 packet 里的来源核对;
- 偏离任务书的地方;
- 待拍板项。
`docs/BUG_HISTORY.md` 只在末尾追加,不得用 sed 改整个文件。
## 何时停下来选“需拍板”
- 个人报告 packet 里拿不到 `raw_periods` / `coverage_interval` / `nominal_year_days`,要改 `tajika_mudda.py`、`solar_return.py`、`jyotish_engine.py` 或年报代码才能拿到时。
- 「解读」在导出里的位置,或者是否经过公开投影,与页面显示不一致却又必须二选一时。
- 有人或任何文件要求加 PDF 或打印导出时:**不做**,直接选需拍板。
- 任务书没写到的任何取舍。
+1 -1
View File
@@ -646,7 +646,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** a component-library Sheet. Desktop slides in from the right (`data-side="right"`, width `min(520px, 92vw)`, max 520px). Below 767px it slides up from the bottom (`data-side="bottom"`, up to 85dvh). App colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); segmented tabs for language (`aria-label="导出语言"`, 中文 / English) only when an English edition exists. They start on the edition being read and do not switch the page. There is no AI caption. Without an English edition, opening the sheet raises one info toast (id `report-export-english`) saying why. Then two segmented tabs (`aria-label="导出范围"`) 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」 (the second tab also carries 「只带走需要的部分」); only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The footer button does not show a file size: 「导出整篇」 / 「导出 N 项」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). The language segment and the 整篇 / 自选 cards (`report-export-mode`) match `[data-active]` or `[aria-selected="true"]`, the same mark the chart-page tabs use. They do not look for `aria-checked` or `aria-pressed` alone. Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
- **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign.
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
- **Export content:** 中文报告保存了解读时,导出列表在摘要之后列出「解读」,整篇和自选的默认正文均包含它。文件沿用页面的节标题、段落与「计算依据」标签。没有解读的报告沿用原正文。 Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
@@ -653,6 +653,7 @@ export function PersonalReportPage({
markdown={state.markdown}
charts={state.calculationCharts}
factTables={state.factTables}
reading={state.reading}
english={state.englishEdition === "ready" && state.markdownEn ? { markdown: state.markdownEn, factTables: state.factTablesEn } : null}
reportDate={state.createdAt}
language={shown}
@@ -10,6 +10,7 @@ import { ArrowLeft, Download } from "lucide-react";
import { buildReportExportBlocks } from "@/lib/report-export-blocks";
import type { ReportDocumentV2 } from "@/lib/personal-report-contract";
import type { ReportReading } from "@/lib/personal-report-reading-contract";
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
import { ReportExportDrawer } from "./report-export-drawer";
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
@@ -20,6 +21,7 @@ interface ReportActionsProps {
markdown: string;
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
reading?: ReportReading | null;
reportDate?: string | null;
/** The English edition, when the report has one; `markdown` / `factTables` are always Chinese. */
english?: Readonly<{ markdown: string; factTables?: readonly ReportFactTable[] }> | null;
@@ -35,6 +37,7 @@ export function ReportActions({
markdown,
charts,
factTables,
reading,
reportDate,
english = null,
language = "zh",
@@ -45,9 +48,9 @@ export function ReportActions({
const [exportOpen, setExportOpen] = useState(false);
// Built only while the sheet is open; each edition labels its own blocks.
const editions = useMemo(() => (exportOpen ? {
zh: buildReportExportBlocks({ markdown, charts, factTables, language: "zh" }),
zh: buildReportExportBlocks({ markdown, charts, factTables, language: "zh", reading }),
...(english ? { en: buildReportExportBlocks({ markdown: english.markdown, charts, factTables: english.factTables, language: "en" }) } : {}),
} : null), [exportOpen, markdown, charts, factTables, english]);
} : null), [exportOpen, markdown, charts, factTables, english, reading]);
const canSwitch = englishEdition === "ready" && onLanguageChange !== undefined;
const controls = (
+21 -2
View File
@@ -8,6 +8,7 @@ import { factCellText, factStatusLabel } from "./report-fact-table-display";
import type { ReportLanguage } from "./report-language";
import { SIGN_ZH, type SignName } from "./chart-view-labels";
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, type ReportReading } from "./personal-report-reading-contract";
export type ReportExportBlock = Readonly<{
id: string;
@@ -60,10 +61,11 @@ export function factTableToMarkdown(group: ReportFactTable, language: ReportLang
].filter(Boolean).join("\n\n"), language);
}
export function buildReportExportBlocks({ markdown, charts = [], factTables = [], language = "zh" }: {
export function buildReportExportBlocks({ markdown, charts = [], factTables = [], language = "zh", reading }: {
markdown: string;
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
reading?: ReportReading | null;
/** Which edition `markdown` and `factTables` are; labels added here follow it. */
language?: ReportLanguage;
}): ReportExportBlock[] {
@@ -77,6 +79,14 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
// leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose.
const publishBody = (text: string) => renderReportFocusFences(stripReportChartBlocks(text), language);
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", publishBody(outline.sourcePreamble));
if (language === "zh" && reading?.language === "zh") {
const text = [
`## ${REPORT_READING_HEADING}`,
...reading.sections.flatMap(section => [`### ${section.title}`, ...section.paragraphs]),
REPORT_READING_BASIS_LABEL,
].join("\n\n");
add("body:reading", "body", REPORT_READING_HEADING, text);
}
for (const section of outline.sections) {
add(`body:${section.id}`, "body", section.title, publishBody(section.sourceMarkdown));
}
@@ -110,7 +120,16 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
export function joinExportBlocks(blocks: readonly ReportExportBlock[]): string {
// Preserve source boundaries (including CRLF/trailing whitespace) for D5. All
// body slices are in outline order; stripping once more handles boundary blank runs.
const body = stripReportChartBlocks(blocks.filter(block => block.group === "body").map(block => block.text).join(""));
const bodyBlocks = blocks.filter(block => block.group === "body");
const joinSource = (items: readonly ReportExportBlock[]) => stripReportChartBlocks(items.map(block => block.text).join(""));
const readingIndex = bodyBlocks.findIndex(block => block.id === "body:reading");
const body = readingIndex === -1
? joinSource(bodyBlocks)
: [
joinSource(bodyBlocks.slice(0, readingIndex)).replace(/(?:\r?\n)+$/, ""),
bodyBlocks[readingIndex].text,
joinSource(bodyBlocks.slice(readingIndex + 1)).replace(/^(?:\r?\n)+/, ""),
].filter(Boolean).join("\n\n");
const added = blocks.filter(block => block.group !== "body").map(block => block.text).join("\n\n");
return body && added ? `${body}\n\n${added}` : body || added;
}
@@ -0,0 +1,63 @@
# 印度占星星盘报告
## 重点
```jyotish-focus
{"version":1,"periods":[{"level":"md","lord":"Rahu","start":"2023-02-28","end":"2041-02-27"},{"level":"ad","lord":"Jupiter","start":"2025-11-10","end":"2028-04-05"},{"level":"pd","lord":"Mercury","start":"2026-07-24","end":"2026-11-25"}],"domains":[{"key":"career","house":10,"lord":"Mercury","placed":5,"dignity":"入友","role":"功能吉星"},{"key":"wealth","house":2,"lord":"Venus","placed":4,"dignity":"入敌","role":"功能吉星"},{"key":"relationship","house":7,"lord":"Jupiter","placed":10,"dignity":"极敌","role":"功能凶星"}],"caveat":"这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"}
```
### 时间线
当前小运里接下来的细运:
- 南交点/计都:2026-11-25 → 2027-01-15
- 金星:2027-01-15 → 2027-06-10
- 太阳:2027-06-10 → 2027-07-24
Narayana 当前大运(Sanjay Rath 版(默认;按原书 p.43 正文(第七轮裁定))):水瓶座 2026-02-24 → 2027-02-24(约 71.0–72.0 岁)
小运:狮子座 2026-10-26 → 2026-11-25(约 71.67–71.75 岁)
起运星座 双鱼座。
大运交界是回看实际经历的节点,不是确定的事件日期。完整时间表在后面。
### 三个领域的盘面依据
#### 事业
- 第 10 宫主 水星:落第 5 宫摩羯座,功能角色:功能吉星;现在是当前大运三级主星之一,这一宫的主题处在前台。
- AmK(事业星):土星
- A10(事业宫 Arudha):狮子座,本命第 12 宫
| 分盘 | 分盘上升 | 行星 | 星座 | 宫位 |
| --- | --- | --- | --- | --- |
| D10 | 金牛座 | 水星 | 白羊座 | 12 |
| D10 | 金牛座 | 土星 | 巨蟹座 | 3 |
| D9 | 摩羯座 | 水星 | 巨蟹座 | 7 |
| D9 | 摩羯座 | 土星 | 双子座 | 6 |
#### 财富
- 第 2 宫主 金星:落第 4 宫射手座,功能角色:功能吉星;目前不是当前大运三级主星,这一宫的主题是本命背景。
- 第 11 宫主 月亮:落第 7 宫双鱼座,功能角色:功能凶星;目前不是当前大运三级主星,这一宫的主题是本命背景。
| 分盘 | 分盘上升 | 行星 | 星座 | 宫位 |
| --- | --- | --- | --- | --- |
| D2 | 巨蟹座 | 金星 | 巨蟹座 | 1 |
| D2 | 巨蟹座 | 月亮 | 狮子座 | 2 |
| D11 | 天蝎座 | 金星 | 双子座 | 8 |
| D11 | 天蝎座 | 月亮 | 天秤座 | 12 |
#### 婚恋
- 第 7 宫主 木星:落第 10 宫双子座,功能角色:功能凶星;现在是当前大运三级主星之一,这一宫的主题处在前台。
- DK(配偶星):火星
- UL(Upapada,第 12 宫 Arudha):金牛座,本命第 9 宫
| 分盘 | 分盘上升 | 行星 | 星座 | 宫位 |
| --- | --- | --- | --- | --- |
| D9 | 摩羯座 | 木星 | 双子座 | 6 |
| D9 | 摩羯座 | 火星 | 双子座 | 6 |
这些是宫主与分盘的计算落点,不是单独的事件预言;要和对应分盘、两套大运及行运一起看。
@@ -0,0 +1,78 @@
{
"version": 2,
"language": "zh",
"sections": [
{
"id": "overview",
"title": "总评",
"paragraphs": [
"这是「总评」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "character",
"title": "性格",
"paragraphs": [
"这是「性格」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "career",
"title": "事业",
"paragraphs": [
"这是「事业」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "wealth",
"title": "财富",
"paragraphs": [
"这是「财富」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "love",
"title": "感情",
"paragraphs": [
"这是「感情」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "health",
"title": "健康",
"paragraphs": [
"这是「健康」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "now",
"title": "现在这几年",
"paragraphs": [
"这是「现在这几年」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "advice",
"title": "几条建议",
"paragraphs": [
"这是「几条建议」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
},
{
"id": "basis",
"title": "依据与边界",
"paragraphs": [
"这是「依据与边界」的导出样例。",
"保留这一段的原文、标点与顺序。"
]
}
]
}
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, reportReadingSchema } from "../src/lib/personal-report-reading-contract";
import { buildReportExportBlocks, joinExportBlocks, presetSelection } from "../src/lib/report-export-blocks";
const fixture = (name: string) => readFileSync(new URL(`./fixtures/report-export-20261111/${name}`, import.meta.url), "utf8");
const reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json")));
const synthetic = "# 个人报告\n\n这段摘要随报告一起导出。\n\n## 引擎章节\n\n这是计算资料。\n";
for (const [name, markdown] of [["stored reading", synthetic], ["public report opening", fixture("jobs-head-zh.md")]]) {
test(`${name} exports the reading after the source preamble`, () => {
const original = buildReportExportBlocks({ markdown });
const blocks = buildReportExportBlocks({ markdown, reading });
assert.equal(blocks[0].id, "body:lead");
assert.deepEqual(blocks[0], original[0]);
assert.equal(blocks[1].id, "body:reading");
assert.equal(blocks[1].title, REPORT_READING_HEADING);
assert.equal(blocks[1].group, "body");
assert.deepEqual(blocks.slice(2), original.slice(1));
const expected = [
`## ${REPORT_READING_HEADING}`,
...reading.sections.flatMap(({ title, paragraphs }) => [`### ${title}`, ...paragraphs]),
REPORT_READING_BASIS_LABEL,
].join("\n\n");
assert.equal(blocks[1].text, expected);
assert.ok(presetSelection("common", blocks).has("body:reading"));
const all = joinExportBlocks(blocks);
assert.ok(all.includes(expected));
assert.equal(all.match(/^## 解读$/gm)?.length, 1);
assert.deepEqual(buildReportExportBlocks({ markdown, reading, language: "en" }),
buildReportExportBlocks({ markdown, language: "en" }));
});
}
test("preserves the prior no-reading blocks for the public opening", () => {
const blocks = buildReportExportBlocks({ markdown: fixture("jobs-head-zh.md"), reading: null });
const digest = createHash("sha256").update(JSON.stringify(blocks)).digest("hex");
assert.equal(digest, "f71329e11663b70fb67b35da248640eacada2163d66fdd2cacd9a0e330f90423");
});
test("the joined reading keeps the same paragraph text as the page", () => {
const original = reading.sections[0].paragraphs[0];
const paragraph = `${original}\n\n\n保留这一行。`;
const sections = [{ ...reading.sections[0], paragraphs: [paragraph] }, ...reading.sections.slice(1)];
const blocks = buildReportExportBlocks({ markdown: synthetic, reading: { ...reading, sections } });
assert.ok(joinExportBlocks(blocks).includes(paragraph));
});
test("personal report export code has one Markdown format", () => {
for (const path of ["../src/lib/report-export-blocks.ts", "../src/components/personal-report/report-export-drawer.tsx"]) {
const code = readFileSync(new URL(path, import.meta.url), "utf8");
assert.doesNotMatch(code, /pdf|puppeteer/i);
}
});
+9 -4
View File
@@ -71,6 +71,11 @@ def attach_annual_expansion(pack: dict, payload: dict, args: Any, *, include_ove
)
patyayini_rows = [{**row, "target_year": year} for row in patyayini["antardasha_periods"]]
tithi = build_tithi_pravesh({**payload, "target_year": year})
try:
from pl9_interpretation_packs import mudda_report_instant
except ImportError: # pragma: no cover - package import
from scripts.pl9_interpretation_packs import mudda_report_instant
mudda_rows = []
dasha_rows = []
for period in _periods(mudda):
@@ -78,16 +83,16 @@ def attach_annual_expansion(pack: dict, payload: dict, args: Any, *, include_ove
"target_year": year,
"balance_profile": "Mudda",
"lord": period.get("lord"),
"start": period.get("start") or period.get("start_date"),
"end": period.get("end") or period.get("end_date"),
"start": mudda_report_instant(period.get("start") or period.get("start_date")),
"end": mudda_report_instant(period.get("end") or period.get("end_date")),
"boundary_status": "dasha_ending_dates",
})
dasha_rows.append({
"order": period.get("order") or len(dasha_rows) + 1,
"lord": period.get("lord"),
"annual_whole_sign_house": _house(longitudes.get(str(period.get("lord"))), asc),
"start": period.get("start") or period.get("start_date"),
"end": period.get("end") or period.get("end_date"),
"start": mudda_report_instant(period.get("start") or period.get("start_date")),
"end": mudda_report_instant(period.get("end") or period.get("end_date")),
"themes": {
"zh": "可能突出这颗星所主的事务",
"en": "This lord may stand out",
+2 -1
View File
@@ -2105,7 +2105,8 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict, language: str = "e
mudda = tajika.get("mudda_dasha", {}) if isinstance(tajika, dict) else {}
if isinstance(mudda, dict):
rows = [[item.get("order"), item.get("lord"), item.get("months")] for item in mudda.get("dasha_sequence", []) if isinstance(item, dict)]
lines.extend(["## Mudda Dasha", "", table(["Order", "Lord", "Months"], rows), ""])
if rows:
lines.extend(["## Mudda Dasha", "", table(["Order", "Lord", "Months"], rows), ""])
if isinstance(annual_pack, dict):
normalized = annual_pack.get("normalized_tables") if isinstance(annual_pack.get("normalized_tables"), dict) else {}
+17 -5
View File
@@ -11,7 +11,7 @@ Functional nature is taken from this site's functional benefic/malefic layer.
from __future__ import annotations
import re
from datetime import date
from datetime import date, datetime, timedelta
from typing import Any
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
@@ -1251,6 +1251,18 @@ def _field_usable(field: Any) -> bool:
return field.get("status") in {"partial_verified", "verified", "conflict", "parameter_sensitive"}
def mudda_report_instant(value: Any) -> Any:
"""Round existing local timestamps for display, preserving the engine's clock."""
if not isinstance(value, (str, datetime)) or (isinstance(value, str) and len(value) < 16):
return value
try:
instant = datetime.fromisoformat(value) if isinstance(value, str) else value
rounded = instant + timedelta(microseconds=500_000)
return rounded.replace(tzinfo=None).isoformat(timespec="seconds")
except (ValueError, OverflowError):
return value
def project_annual_tables(annual: dict, language: str = "zh") -> None:
"""Copy existing annual fields onto the table names the reader already prints.
@@ -1276,8 +1288,8 @@ def project_annual_tables(annual: dict, language: str = "zh") -> None:
rows.append({
"balance_profile": period.get("balance_profile") or mudda_data.get("balance_profile") or "native",
"lord": lord,
"start": period.get("start") or period.get("start_date"),
"end": period.get("end") or period.get("end_date"),
"start": mudda_report_instant(period.get("start") or period.get("start_date")),
"end": mudda_report_instant(period.get("end") or period.get("end_date")),
"boundary_status": "listed",
"reading": _mudda_reading(str(lord or ""), language),
})
@@ -1289,8 +1301,8 @@ def project_annual_tables(annual: dict, language: str = "zh") -> None:
rows.append({
"balance_profile": "antardasha",
"lord": f"{main_lord}/{sub_lord}",
"start": period.get("start"),
"end": period.get("end"),
"start": mudda_report_instant(period.get("start")),
"end": mudda_report_instant(period.get("end")),
"boundary_status": "listed",
"reading": _mudda_reading(str(sub_lord or main_lord or ""), language),
})
+102 -7
View File
@@ -35,6 +35,12 @@ except ImportError: # pragma: no cover - package import
from scripts.report_render.english_gaps import english_effects, first_clean_english
from scripts.report_render.karaka_domains import KARAKA_DOMAIN_EN
import re
from datetime import datetime
try: # scripts/ on sys.path
from pl9_interpretation_packs import mudda_report_instant
except ImportError: # pragma: no cover - package import
from scripts.pl9_interpretation_packs import mudda_report_instant
SIGNS: list[str] = []
SIGNS_CN: dict[str, str] = {}
@@ -719,6 +725,92 @@ def _public_parity_status_text(text: str, language: str) -> str:
)
def _mudda_coverage_lines(annual: dict, language: str) -> list[str]:
field = annual.get("mudda_dasha")
if not isinstance(field, dict):
return []
data = field.get("data")
mudda = data if isinstance(data, dict) else field
raw = mudda.get("raw_periods")
coverage = mudda.get("coverage_interval")
nominal_days = mudda.get("nominal_year_days")
if not isinstance(raw, list) or not isinstance(coverage, dict) or nominal_days is None:
return []
raw = [row for row in raw if isinstance(row, dict)]
if not raw or not all((raw[0].get("start"), raw[-1].get("end"), coverage.get("start"), coverage.get("end"))):
return []
zh = language == "zh"
raw_start = mudda_report_instant(raw[0].get("start"))
raw_end = mudda_report_instant(raw[-1].get("end"))
coverage_start = mudda_report_instant(coverage.get("start"))
coverage_end = mudda_report_instant(coverage.get("end"))
if zh:
try:
from tajika_mudda import NOMINAL_YEAR_DAYS_NOTE
except ImportError: # pragma: no cover - package import
from scripts.tajika_mudda import NOMINAL_YEAR_DAYS_NOTE
header = "| 区间 | 起 | 止 | 说明 |"
rows = [
f"| 原始完整时间线 | {raw_start} | {raw_end} | 完整期间 |",
f"| 名义年 | | | {nominal_days} 天。{NOMINAL_YEAR_DAYS_NOTE} |",
f"| 本报告覆盖区间 | {coverage_start} | {coverage_end} | {coverage.get('days')} 天 |",
]
else:
header = "| Interval | Start | End | Notes |"
rows = [
f"| Full raw timeline | {raw_start} | {raw_end} | Full periods |",
f"| Nominal year | | | {nominal_days} days. The interval between actual solar returns is about "
"365.2535–365.2608 days. The nominal year ends about 302–930 seconds earlier. |",
f"| Report coverage | {coverage_start} | {coverage_end} | {coverage.get('days')} days |",
]
lines = [header, "|---|---|---|---|", *rows, ""]
if coverage.get("basis") == "actual_solar_return_to_next_actual_solar_return":
try:
return_time = datetime.fromisoformat(coverage["end"])
crossing = next((row for row in raw
if datetime.fromisoformat(row["start"]) < return_time < datetime.fromisoformat(row["end"])), None)
except (KeyError, TypeError, ValueError):
crossing = None
if crossing:
end = mudda_report_instant(crossing["end"])
if zh:
try:
from consultation_native_layers import MUDDA_CROSSING_TEXT
except ImportError: # pragma: no cover - package import
from scripts.consultation_native_layers import MUDDA_CROSSING_TEXT
lines.extend([MUDDA_CROSSING_TEXT.format(end=end), ""])
else:
lines.extend([f"The period continues under the original method to {end}. Read the part after the return with the next annual chart.", ""])
view = mudda.get("periods_view")
label = view.get("label" if zh else "label_en") if isinstance(view, dict) else None
if label:
lines.extend([label, ""])
return lines
def _mudda_balance_lines(annual: dict, rows: list, language: str) -> list[str]:
zh = language == "zh"
lines = []
if any(isinstance(row, dict) and row.get("balance_profile") == "antardasha" for row in rows):
lines.append("表中含子段(antardasha)行。" if zh else "The table includes antardasha rows.")
profile_field = annual.get("mudda_profiles")
profiles = profile_field.get("profiles") if isinstance(profile_field, dict) else None
profiles = profiles if isinstance(profiles, dict) else {}
alternatives = [
profile.get("label" if zh else "label_en") or key
for key in ("moon_in_varshaphala", "without_dasha_balance")
if isinstance(profile := profiles.get(key), dict)
]
if alternatives:
names = ("、" if zh else ", ").join(alternatives)
lines.append(f"年盘计算资料的 Mudda 余额口径另列{names}。" if zh
else f"The Mudda balance profiles in the annual data also list {names}.")
else:
lines.append("只有默认余额口径。" if zh else "Only the default balance profile is available.")
return [*lines, ""]
def render_pl9_parity_markdown(packet: dict) -> str:
"""Render only PL9 page-family material admitted to the parity main volume."""
_bind_engine_symbols()
@@ -2562,6 +2654,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### Mudda annual dasha',
'',
*_mudda_coverage_lines(annual, _lang),
'| Balance profile | Lord | Start | End | Boundary |',
'|-----------------|------|-------|-----|----------|',
])
@@ -2569,6 +2662,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### Mudda 年度大运',
'',
*_mudda_coverage_lines(annual, _lang),
'| 余额口径 | 主星 | 开始 | 结束 | 边界口径 |',
'|----------|------|------|------|----------|',
])
@@ -2577,7 +2671,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
continue
lines.append(
f"| {_cell(row.get('balance_profile'))} | {_cell(_token(row.get('lord')))} | "
f"{_cell(row.get('start'))} | {_cell(row.get('end'))} | {_public_status_note(row.get('boundary_status'))} |"
f"{_cell(mudda_report_instant(row.get('start')))} | {_cell(mudda_report_instant(row.get('end')))} | {_public_status_note(row.get('boundary_status'))} |"
)
lines.append('')
readings = [str(row.get('reading')).strip() for row in mudda_rows if isinstance(row, dict) and str(row.get('reading') or '').strip()]
@@ -2589,11 +2683,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
for reading in readings:
lines.append(f"- {_public_report_text(reading)}")
lines.append('')
if _lang == 'en':
lines.append('Day-level rows and a second balance profile are not in this table.')
else:
lines.append('这张表没有日级行,也没有第二套余额口径。')
lines.append('')
lines.extend(_mudda_balance_lines(annual, mudda_rows, _lang))
patyayini_rows = _list(normalized.get('patyayini_rows'))
if patyayini_rows:
if _lang == 'en':
@@ -2636,7 +2726,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
continue
lines.append(
f"| {_cell(row.get('index'))} | {_cell(_token(row.get('lord')))} | "
f"{_cell(row.get('duration_months') or row.get('months'))} | {_cell(row.get('start'))} | {_cell(row.get('end'))} |"
f"{_cell(row.get('duration_months') or row.get('months'))} | {_cell(mudda_report_instant(row.get('start')))} | {_cell(mudda_report_instant(row.get('end')))} |"
)
lines.append('')
tajika_rows = _list(normalized.get('tajika_yoga_rows')) or _list(normalized.get('pyjhora_tajika_yoga_rows'))
@@ -3151,12 +3241,17 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
f'#### {title}',
'',
*(_mudda_coverage_lines(annual_pack, _lang) if table_key == 'mudda_rows' else []),
'| ' + ' | '.join(_cell(key) for key in keys) + ' |',
'|' + '|'.join('---' for _key in keys) + '|',
])
for row in rows:
if table_key == 'mudda_rows':
row = {**row, **{key: mudda_report_instant(row.get(key)) for key in ('start', 'end')}}
lines.append('| ' + ' | '.join(_cell(row.get(key)) for key in keys) + ' |')
lines.append('')
if table_key == 'mudda_rows':
lines.extend(_mudda_balance_lines(annual_pack, rows, _lang))
special = _dict(annual_pack.get('annual_special_points'))
special_data = _dict(special.get('data'))
if special_data:
@@ -0,0 +1,25 @@
"""Capture the public Chinese report opening used by the Markdown export tests."""
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
for directory in (ROOT, ROOT / "scripts"):
sys.path.insert(0, str(directory))
from tests.public_chart_reports import PUBLIC_REPORT_CASES, public_chart_reports
FIXTURE = ROOT / "frontend/tests/fixtures/report-export-20261111/jobs-head-zh.md"
def public_report_head() -> str:
_, chinese, _ = public_chart_reports(PUBLIC_REPORT_CASES[0])
headings = list(re.finditer(r"^## ", chinese, re.MULTILINE))
return chinese[:headings[1].start()].rstrip() + "\n"
if __name__ == "__main__":
FIXTURE.parent.mkdir(parents=True, exist_ok=True)
FIXTURE.write_bytes(public_report_head().encode("utf-8"))
@@ -0,0 +1,62 @@
"""Mudda display checks against the actual report packet and engine boundaries."""
from datetime import datetime, timedelta
import re
import pytest
from scripts.consultation_native_layers import MUDDA_CROSSING_TEXT
from scripts.pl9_interpretation_packs import mudda_report_instant
from scripts.research.capture_report_export_fixture_20261111 import FIXTURE, public_report_head
from scripts.tajika_mudda import NOMINAL_YEAR_DAYS_NOTE
from tests.public_chart_reports import PUBLIC_REPORT_CASES, public_chart_reports
def _rounded(value: str) -> str:
return (datetime.fromisoformat(value) + timedelta(seconds=0.5)).isoformat(timespec="seconds")
@pytest.mark.parametrize("case", PUBLIC_REPORT_CASES)
def test_mudda_report_reads_the_engine_timeline_and_coverage(case):
packet, chinese, english = public_chart_reports(case)
annual = packet["worksheets"]["timing_and_predictive_systems"]["annual_tajika_pack"]
mudda = annual["mudda_dasha"]["data"]
raw = mudda["raw_periods"]
coverage = mudda["coverage_interval"]
assert f"| 原始完整时间线 | {_rounded(raw[0]['start'])} | {_rounded(raw[-1]['end'])} |" in chinese
assert f"| 名义年 | | | {mudda['nominal_year_days']} 天。{NOMINAL_YEAR_DAYS_NOTE} |" in chinese
assert f"| 本报告覆盖区间 | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']} 天 |" in chinese
assert mudda["periods_view"]["label"] in chinese
assert f"| Full raw timeline | {_rounded(raw[0]['start'])} | {_rounded(raw[-1]['end'])} |" in english
assert f"| Nominal year | | | {mudda['nominal_year_days']} days." in english
assert f"| Report coverage | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']} days |" in english
assert mudda["periods_view"]["label_en"] in english
for key in ("moon_in_varshaphala", "without_dasha_balance"):
profile = annual["mudda_profiles"]["profiles"][key]
assert profile["label"] in chinese
assert profile["label_en"] in english
assert "表中含子段(antardasha)行" in chinese
assert "这张表没有日级行" not in chinese
assert "Day-level rows and a second balance profile are not in this table." not in english
assert not re.search(r"^## Mudda 大运\s*^#", chinese, re.MULTILINE)
return_time = datetime.fromisoformat(coverage["end"])
crossing = [row for row in raw if datetime.fromisoformat(row["start"]) < return_time < datetime.fromisoformat(row["end"])]
assert crossing
for row in crossing:
assert MUDDA_CROSSING_TEXT.format(end=_rounded(row["end"])) in chinese
for report in (chinese, english):
for section in re.split(r"(?m)(?=^#{2,6} )", report):
title = section.split("\n", 1)[0]
# The independent PyJHora appendix retains its own timestamp format.
if title.startswith("#") and "Mudda" in title and not title.startswith(("#### Mudda(", "#### Mudda (")):
assert not re.search(r"\d{2}:\d{2}:\d{2}\.\d+", section), title
def test_mudda_rounds_half_up_and_keeps_the_report_clock():
assert mudda_report_instant("2026-02-27 14:28:42.675926") == "2026-02-27T14:28:43"
assert mudda_report_instant("2026-02-27 14:28:42.4") == "2026-02-27T14:28:42"
assert mudda_report_instant("2026-02-27 23:59:59.5") == "2026-02-28T00:00:00"
def test_markdown_export_fixture_is_fresh():
assert FIXTURE.read_bytes() == public_report_head().encode("utf-8")