fix(report): fill the full-data edition and clear English Han
Add the interpretation pages from fields the engine already computed, project the annual tables the reader prints, and clear English Han on the four measured charts. Patyayini is not implemented. The generate button copy is unchanged. This commit stays on the feature branch and is not pushed to staging.
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
- 行星、上升和宫头以前用含章动的视位置,减去不含章动的平岁差,全体偏一个很小的角度。现在与 Swiss Ephemeris 的恒星黄道使用同一套章动(BUG-1275)。
|
||||
- 大运日期可能因此前后移动 2–3 天。同一份生时校正输入的分数会变。算法身份从 scoring-11 升到 scoring-12。以前做好的校正记录仍能打开,只读,并可以按新算法再比一次。Skill 版本不变。
|
||||
|
||||
## 2026-10-08 — 完整数据版补上解释,英文可以生成(未上线)
|
||||
|
||||
- 个人报告的中文补上大运、行星、宫主、Dosha 和瑜伽的解释,开篇「重点」写当前三级大运和事业、财富、婚恋三宫的宫主。Patyayini 仍没有实现。
|
||||
- 乔布斯、奥巴马、爱因斯坦和一张虚构盘的英文不再夹汉字,新报告可以切换「中文 / English」(BUG-1272)。「Bhava 年龄状态」「行星s」这两处误译已改(BUG-1274)。
|
||||
- 生成按钮的时长说明没改。以前生成的报告仍按当时的正文打开。不改数据库。Skill 版本不变。
|
||||
|
||||
## 2026-10-08 — 个人报告改为完整数据版(未上线)
|
||||
|
||||
- 「生成报告」改为完整数据版:星盘、力量、大运、年运和瑜伽写进正文,后面有一份结构化资料附录。阅读版的生成入口已删除。
|
||||
|
||||
+25
-9
@@ -17117,19 +17117,19 @@
|
||||
|
||||
## BUG-1272 | 完整数据版英文报告清理后仍含汉字,接口整份撤回英文
|
||||
|
||||
- 状态:investigating
|
||||
- 状态:resolved(本分支 `codex/report-full-data-edition-20261007`,未合入 staging,未上线)
|
||||
- 首次发现 / 最近更新:2026-10-08 / 2026-10-08
|
||||
- 来源:`TASK-report-full-data-edition-20261007`。上游任务书已记录乔布斯盘英文清理失败(`chinese_characters:14981`)。
|
||||
- 来源:`TASK-report-full-data-edition-20261007`。上游任务书已记录乔布斯盘英文清理失败(`chinese_characters:14981`)。补齐见 `TASK-report-full-data-fix-20261008`。
|
||||
- 影响面:新生成的个人报告英文版。中文完整数据版不受影响。已生成的旧报告仍按当时保存的正文打开。
|
||||
- 现象:完整数据版英文沿用上游清理器。清理器对没有译文的汉字不会删行,而是包成 `[source text retained: …]`。这样正文里仍然有汉字。公开接口发现汉字后设置 `english_unavailable=han_leak`,不返回 `markdown_en`。页面不显示「中文 / English」切换。
|
||||
- 触发条件:用完整数据版生成中英两份。本机虚构盘、乔布斯盘、奥巴马盘都如此。泰勒盘没有公开出生资料,没有生成。
|
||||
- 根因:英文清理器的设计是留下缺译文的原文,避免看起来干净、实际少了证据。本仓资料包里这类汉字不是一小张术语表:补译分盘提示、行星主星标题、八分法标题和「合计」之前,三张盘大约有 350–470 处汉字串、240–300 个不同写法,很多是单字或半句。不能靠手写词典清空,也不能把夹汉字的英文交给用户。
|
||||
- 修复:未解决。生成路径已经整份撤回英文,不输出中英夹杂的英文报告。清理器里的汉字标记只留在生成过程中。
|
||||
- 验证:`tests/test_report_english_edition.py` 要求要么英文没有汉字,要么接口撤回且不带 `markdown_en`。没有在浏览器里走查。
|
||||
- 防复发:沿用阅读版泄漏词表检查英文全文;接口和前端都在发现汉字时撤回英文。汉字清零之前不得把这则标成 resolved。
|
||||
- 相关记录:BUG-1126(有英文版时切换必须真换正文)、BUG-1271。
|
||||
- 触发条件:用完整数据版生成中英两份。本机虚构盘、乔布斯盘、奥巴马盘都如此。泰勒盘没有公开出生资料,没有生成。第四张公开盘用爱因斯坦。
|
||||
- 根因:短词替换会先把整句拆开,剩下的单字和半句再被包进 source text retained。早期大约 350–470 处汉字串,是拆开之后的碎片,不是必须整段删掉的正文。双主星有一条中文依据,资料包里本来就有英文对照,阅读时却只印了中文,单字「水」又把「水瓶」拆成了 Mercury。
|
||||
- 修复:章节标题按语言写;拆开后剩下的碎片补进英文术语,不删段落。双主星说明在英文里改用已有的英文依据。清理器仍把没有译文的汉字包起来。接口发现汉字后仍整份撤回英文。
|
||||
- 验证:虚构、爱因斯坦、乔布斯、奥巴马四张盘英文汉字数为 0,中英二级标题都是 35/35。`tests/test_report_english_edition.py` 改为要求三张虚构盘汉字为 0,并且虚构日盘的接口必须交回英文。故意写入汉字的测试仍要求 `english_unavailable=han_leak`。没有在浏览器里走查。
|
||||
- 防复发:上述测试。接口和前端仍在发现汉字时撤回英文。
|
||||
- 相关记录:BUG-1126(有英文版时切换必须真换正文)、BUG-1271、BUG-1274。
|
||||
- 复发自:无。
|
||||
- 修复版本:无。
|
||||
- 修复版本:本分支,未合入 staging。
|
||||
|
||||
## BUG-1273 | 生产镜像没有 PyJHora,八个特殊上升点算不出来
|
||||
|
||||
@@ -17149,6 +17149,22 @@
|
||||
- Claude 验收(2026-10-08,Linux):屏蔽 `jhora` 导入后跑 `cmd_full_reading`,乔布斯、奥巴马、虚构盘 × Lahiri / Raman 共 6 组,8 个点全部 `computed`、`source` 为 `native_special_lagna`,与 `fb8277bf` 调 PyJHora 的结果最大差 0°。Python 全量 62 条失败,与 `2df6ee72` 逐名相同;快速门通过;`tests/test_runtime_jhora_import_scan.py` 在快速门内,人为在 `native_special_lagnas.py` 加一行 `from jhora ...` 后该测试失败(变异检查)。年运资料包屏蔽 `jhora` 前后逐字节相同。
|
||||
- 待问占星师:Vighati 上升按每分钟 15° 计(与 PyJHora 一致);按字面「每个 vighati 一个星座」应为每分钟 75°。
|
||||
|
||||
## BUG-1274 | 中文报告把 Bhava Bala 译成年龄状态,并把 Planets 译成行星s
|
||||
|
||||
- 状态:resolved(本分支 `codex/report-full-data-edition-20261007`,未合入 staging,未上线)
|
||||
- 首次发现 / 最近更新:2026-10-08 / 2026-10-08
|
||||
- 来源:`TASK-report-full-data-fix-20261008` 的译名项。
|
||||
- 影响面:完整数据版中文报告的力量标题和行星标题。
|
||||
- 现象:标题出现「六维力量 / Bhava 年龄状态」和「Natal / KP Ruling 行星s」。
|
||||
- 触发条件:生成完整数据版中文。清理器先把 Shadbala 写成「六维力量」,再把单独的 Bala 写成「年龄状态」,于是修 Bhava Bala 的那一步对不上原文。另一处把 Planet 换成「行星」时没有先处理 Planets,复数的 s 留在汉字后面。
|
||||
- 根因:两段替换都按较短的词下手,较长的词还在句里。
|
||||
- 修复:先替换整段「Natal / KP Ruling Planets」「Planets」「Planetary」,再替换 Planet。力量标题改回「六维力量 / Bhava Bala」。星体年龄那一列仍叫年龄状态,没有全局改掉。
|
||||
- 验证:`tests/test_report_full_data_fix.py` 检查这两句,并检查中文不得出现「英文单词 + 汉字 + s」。
|
||||
- 防复发:同一条测试。
|
||||
- 相关记录:BUG-1272。
|
||||
- 复发自:无。
|
||||
- 修复版本:本分支,未合入 staging。
|
||||
|
||||
## BUG-1275 | 恒星黄道经度少扣章动:所有行星偏约 0.005°,Vimshottari 日期差 2–3 天
|
||||
|
||||
- 状态:resolved(代码;部署以 `/api/health` 的 `deployment.gitCommit` 为准)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 进度 · 完整数据版补齐(2026-10-08)
|
||||
|
||||
- 分支:`codex/report-full-data-edition-20261007`
|
||||
- 基线:`e14fa377`(完整数据版那一笔,已经 rebase 到 `origin/staging` `16782634` 之上;特殊上升点 `795b8845` 仍是祖先)
|
||||
- 状态:代码在本分支,**未合入 staging**,未上线。等 Claude 验收通过后再合入
|
||||
- 远端功能分支上的旧提交 `06028759` 不是这次 rebase 之后的祖先。更新功能分支需要 `--force-with-lease`,不能推 `staging`
|
||||
|
||||
## 结论
|
||||
|
||||
新报告仍是完整数据版。中文补上了大运、行星、宫主、Dosha、瑜伽解释,以及开篇「重点」。英文四张盘汉字数为 0,接口会返回英文。Patyayini 没有实现,没有新写计算器。生成按钮文案没改。
|
||||
|
||||
## 四张盘(本机,一次一个进程)
|
||||
|
||||
汉字范围用的是接口同一条规则。没有把出生时间写进这份记录。
|
||||
|
||||
| 盘 | 中文字符 | 英文字符 | 英文汉字 | 二级标题(中/英) | 缺字段 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 虚构 | 212336 | 361577 | 0 | 35/35 | 6 |
|
||||
| 爱因斯坦 | 234199 | 402881 | 0 | 35/35 | 6 |
|
||||
| 乔布斯 | 209717 | 359869 | 0 | 35/35 | 6 |
|
||||
| 奥巴马 | 222602 | 380264 | 0 | 35/35 | 6 |
|
||||
|
||||
四份中文都有「大运解释」「行星、星宿与宫位解释」「宫主逐宫解释」「Dosha 逐项」「瑜伽解释」,开篇「重点」里有 MD、AD、PD。中文没有「再婚」「几段关系」「早夭」「一定会」「必然」「疾病诊断」「疾病」。英文没有 divorce、miscarriage、infertility、guarantee。决策 4 的七种大运标题没有出现。中文都超过约 20 万字,阅读页会一次显示一章。
|
||||
|
||||
另有一张任务书测试里的夜盘,双主星说明原来会留下汉字。英文改用资料包里已经写好的英文依据之后,正文和适用性说明的汉字也是 0。接口因此会交出英文。
|
||||
|
||||
## 章节目录
|
||||
|
||||
| 状态 | 章节 |
|
||||
| --- | --- |
|
||||
| 已有 | 大运解释、行星解释、宫主逐宫、Dosha 逐项、瑜伽解释、Mudda、Sahams、Tajika 瑜伽、Mudda 月段窗口、太阳返照 |
|
||||
| 本单不做(决策 4) | Sthira、Drig、Shoola、Navamsha、Niryaana Shoola、Lagna Kendradi、Chara。不写占位 |
|
||||
| 本单不做(让步,月返照 / 八年总览) | 月返照盘、八年总览。目录标成延后,不算进缺字段 |
|
||||
| 缺字段 | Patyayini(两节同一个来源,只计一次;正文写「没有实现」)、Raman Sahams(不用普通 Sahams 冒充)、一生年运图、Tripataki、年运特殊点、当年结果说明。合计 6,不超过 10 |
|
||||
|
||||
## 让步
|
||||
|
||||
1. T6 生成按钮不改。`frontend/docs/VOICE.md` 和现有测试锁着「大约 10–30 秒」。本机不是 staging 的 2 核计时,不新编一段分钟数。卡片里的「中英两版」留着,因为英文现在能生成。
|
||||
2. T3 没有让。开篇「重点」写当前 MD / AD / PD,以及事业、财富、婚恋三宫的宫主、尊贵和功能吉凶。
|
||||
3. T2 的月返照和八年总览按让步顺序放下,没有新算。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_report_full_data_fix.py` | 4 条通过 |
|
||||
| `tests/test_report_english_edition.py` | 18 条通过。含上面两处改过的断言 |
|
||||
| 运行时不得导入 jhora | `tests/test_runtime_jhora_import_scan.py` 4 条通过 |
|
||||
| ruff(改过的四个脚本加新测试) | 8 条都是改动前就有的,没有改格式去消 |
|
||||
| Linux 快速门、Python 全量、`npm test`、`next build` | 本机没跑,不能当成通过 |
|
||||
|
||||
改过的既有断言:
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 英文无汉字 | 有汉字就必须包在 source text retained 里 | 三张虚构盘汉字数为 0 | BUG-1272 已清零 |
|
||||
| 接口是否交英文 | 没汉字就交,有汉字就整份撤回 | 虚构日盘必须交回无汉字的英文 | 同上。故意写进汉字的那条测试仍要求撤回 |
|
||||
|
||||
没有改 `scripts/jyotish_api_server.py`,没有改 `Home()` 的状态数,没有改默认报告版本,没有改生时校正公式,没有改生成按钮。
|
||||
@@ -202,8 +202,8 @@
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 未合入(验收未通过,见 fix-20261008) | `codex/report-full-data-edition-20261007`(未推 staging) |
|
||||
| `TASK-report-full-data-fix-20261008.md` | — | **完整数据版补齐**(同分支继续):中文只有上游 19%(9.9 万 vs 52.6 万字);产品允许移植上游解释生成器并逐条核口径;年运表接我方字段;英文必须交付才上线;其他 7 种大运另开单 | 待领取 | — |
|
||||
| `TASK-report-full-data-fix2-20261008.md` | — | **完整数据版第二轮**(同分支 `32f19f07` 续):英文已过;未过——全量 +1 旧断言、寿命断言、半译词与字段名被改坏、「守护第未列出宫」163 处、年运章 6/29 节、大运解释只有上游 42%(产品:补回传统段落改「可能」口吻) | 待领取 | — |
|
||||
| `TASK-report-full-data-fix-20261008.md` | `PROGRESS-report-full-data-fix-20261008.md` | **完整数据版补齐**(同分支继续):解释段、年运字段、开篇重点、英文汉字清零。Patyayini 不新算。生成按钮文案不改。等验收后再合入 staging | 验收未通过(见 fix2) | `codex/report-full-data-edition-20261007`(未合入 staging) |
|
||||
| `TASK-report-full-data-fix2-20261008.md` | — | **完整数据版第二轮**(同分支续):英文已过;未过——全量 +1 旧断言、寿命断言、半译词与字段名被改坏、「守护第未列出宫」163 处、年运章 6/29 节、大运解释只有上游 42%(产品:补回传统段落改「可能」口吻) | 待领取 | — |
|
||||
| `TASK-report-reader-main-fix-20260925.md` | `PROGRESS-report-reader-main-fix-20260925.md` | **读者版修复**:真实年主比较、分字段绑定与未来返照;保留 Bhava Bala 和标题,隔离报告/聊天规则 | 已验收(Claude 09-25:年运表逐年填齐且不同、Bhava Bala 保留、泄漏 0) | `4d801e53`(已部署,health 核对一致) |
|
||||
| `TASK-report-reader-main-20260924.md` | `PROGRESS-report-reader-main-20260924.md` | **报告正文换上游读者版 reader_main + KP / Muntha 计算缺陷 + 投影截断(BUG-1026/1027/1028)**:正文一直是上游审计版(读者版 09-09 才出、从未同步),本仓又自加「结论等级规则」/英文段/异常原文;投影黑名单不认 PL9 词汇且截断半句、删表留说明;KP args `vars()` 拷贝为空恒失败;Muntha 漏合 `birth_asc_sign_idx` → Year Lord 恒火星。产品拍板接入读者版、旧报告不回填。 | 部分通过(Claude 09-25:读者版切换、泄漏 0 命中、KP 已修;年运 Year Lord/Muntha 全为「-」P1、投影误删 Bhava Bala P2 → 修复单) | `3c2f7bd5`(已部署) |
|
||||
| `TASK-report-reader-actions-20260924.md` | — | **报告操作收敛**:列表只留「查看报告」;阅读页删「下载原始附录」「打印」,导出改图标按钮;导出对话框 ≥860 居中、窄屏仍贴底(修订 chapter-export D9/D10)。产品变更,不开 BUG 号 | 基本通过(Claude 09-24:入口收敛、图标、≥860 居中均符合;原始附录三文件因执行方权限未删、PROGRESS/清单未写 → 并入修复单) | `f125fae0`(未部署) |
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 真机清单 · 完整数据版补齐(2026-10-08)
|
||||
|
||||
对应 `docs/tasks/TASK-report-full-data-fix-20261008.md`。电脑和手机各走一遍。第 1 步会用掉一次生成次数。本机没有登录态,也没有用浏览器点过,下表结果留空。
|
||||
|
||||
这一轮和上一份清单的差别,供对照:
|
||||
|
||||
- 新报告中文大约 21 万到 23 万字,超过约 20 万字的分页线。阅读页一次只显示一章,目录和「上一章 / 下一章」可以换章。
|
||||
- 四张核对过的盘,英文汉字数为 0。新生成的报告应出现「中文 / English」。点过去正文和目录一起换,停在同一章。
|
||||
- 生成卡片仍写「大约 10–30 秒」和「中英两版」。时长不是 staging 上量的,这轮没有改文案。
|
||||
- 以前生成的报告仍按当时保存的正文打开,不会自动变成这一版。
|
||||
|
||||
| # | 操作 | 应该看到 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 「我的报告」→ 生成报告,等它完成 → 打开 | 正文是中文。有「中文 / English」。有「上一章 / 下一章」 | |
|
||||
| 2 | 点「English」,再点「中文」 | 正文和目录换成另一种语言,还停在刚才那一章。英文里看不到汉字 | |
|
||||
| 3 | 点目录里的某一章 | 正文跳到那一章。页面不卡住 | |
|
||||
| 4 | 点「导出」 | 只有这一处导出。新报告可以选择中文或 English,下载的是 Markdown。不出现「英文版没有生成成功」 | |
|
||||
| 5 | 打开一份这次更新之前生成的旧报告 | 能打开,正文仍是当时保存的那一版,没有自动重算 | |
|
||||
| 6 | 在旧报告地址后手动加 `?lang=en` | 仍显示原来的正文,不报错 | |
|
||||
@@ -212,6 +212,11 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
|
||||
text = text.replace("Natural friends", "天然友星")
|
||||
text = text.replace("Natural enemies", "天然敌星")
|
||||
text = text.replace("Temporary relation", "临时关系")
|
||||
text = text.replace("Natal / KP Ruling Planets", "本命 / KP 征象星")
|
||||
text = text.replace("Ruling Planets", "征象星")
|
||||
text = text.replace("Sub-Planets", "副行星")
|
||||
text = text.replace("Planetary", "行星")
|
||||
text = text.replace("Planets", "行星")
|
||||
text = text.replace("Planet", "行星")
|
||||
text = text.replace("Sign", "星座")
|
||||
text = text.replace("Degree in sign", "落座度数")
|
||||
@@ -409,6 +414,9 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
|
||||
"Divisional Charts Numeric Tables": "分盘数值位置表",
|
||||
"Sensitive Points": "敏感点",
|
||||
"Upagraha and Sub-行星 Points": "Upagraha 与副行星点",
|
||||
"Upagraha and 副行星 Points": "Upagraha 与副行星点",
|
||||
"行星 Friendship Matrix": "行星友敌关系矩阵",
|
||||
"行星 Friendship 矩阵源数据": "行星友敌关系矩阵源数据",
|
||||
"六维力量 Components": "六维力量细项",
|
||||
"Functional 自然吉星 / 自然凶星 Layer": "功能性吉凶星层",
|
||||
"Bhava 年龄状态 宫位 强度": "十二宫力量",
|
||||
@@ -1123,6 +1131,18 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
|
||||
"表达受阻,可能表现为说话不顺、迟疑或难以清楚表达": "speech may be obstructed, hesitant or difficult to express clearly",
|
||||
"思绪容易扰动,需留意情绪、压力与判断稳定性": "the mind may be disturbed; emotional pressure and judgment stability need care",
|
||||
"体型偏瘦,体质较敏感": "leaner body type and sensitive constitution",
|
||||
"水瓶": "Aquarius",
|
||||
"天蝎": "Scorpio",
|
||||
"白羊": "Aries",
|
||||
"摩羯": "Capricorn",
|
||||
"金牛": "Taurus",
|
||||
"双子": "Gemini",
|
||||
"巨蟹": "Cancer",
|
||||
"狮子": "Leo",
|
||||
"处女": "Virgo",
|
||||
"天秤": "Libra",
|
||||
"射手": "Sagittarius",
|
||||
"双鱼": "Pisces",
|
||||
"火": "Mars",
|
||||
"水": "Mercury",
|
||||
"木": "Jupiter",
|
||||
@@ -2110,16 +2130,61 @@ _SITE_BHAVA_SCORE = re.compile(r"\(Benefic\):\s*\+2|\(Malefic\):\s*-1\.5|吉星\
|
||||
_NEECHA_RAJA = re.compile(r"Neecha Bhanga Raja Yoga|落陷取消王瑜伽|坏座位消王")
|
||||
|
||||
|
||||
def _apply_local_report_conventions(markdown: str) -> str:
|
||||
def _apply_local_report_conventions(markdown: str, *, language: str = "zh") -> str:
|
||||
"""Keep this site's report wording inside the upstream appendix."""
|
||||
text = _NEECHA_RAJA.sub("落陷取消(Neecha Bhanga)", markdown)
|
||||
if language == "en":
|
||||
text = _NEECHA_RAJA.sub(
|
||||
"Neecha Bhanga (debilitation cancellation, not a Raja Yoga)",
|
||||
markdown,
|
||||
)
|
||||
text = re.sub(r"\bguaranteed\b", "certain", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bguarantee\b", "certain outcome", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bdivorce\b", "relationship strain", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bmiscarriage\b", "a sensitive health topic", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\binfertility\b", "a sensitive fertility topic", text, flags=re.IGNORECASE)
|
||||
else:
|
||||
text = _NEECHA_RAJA.sub("落陷取消(Neecha Bhanga)", markdown)
|
||||
text = text.replace("疾病诊断", "身体负荷话题")
|
||||
text = text.replace("一定会", "有可能")
|
||||
text = re.sub(r"(?<!不是)必然", "可能", text)
|
||||
text = text.replace("再婚", "关系变化")
|
||||
text = text.replace("几段关系", "关系主题")
|
||||
text = text.replace("早夭", "早年体质")
|
||||
text = text.replace("疾病", "身体负荷")
|
||||
text = text.replace("落陷取消王", "落陷取消")
|
||||
text = _SITE_BHAVA_SCORE.sub("", text)
|
||||
return text
|
||||
|
||||
|
||||
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:
|
||||
return markdown
|
||||
match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE)
|
||||
if not match:
|
||||
return focus.rstrip() + "\n\n" + markdown
|
||||
return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :]
|
||||
|
||||
|
||||
def _interpretation_tools():
|
||||
try:
|
||||
from pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block
|
||||
except ModuleNotFoundError: # pragma: no cover - package import compatibility
|
||||
from scripts.pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block
|
||||
return attach_full_data_interpretation, opening_focus_block
|
||||
|
||||
|
||||
def render_full_data_markdown(packet: dict) -> str:
|
||||
"""Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body."""
|
||||
if _pl9_report_language(packet) == "en":
|
||||
attach_full_data_interpretation, opening_focus_block = _interpretation_tools()
|
||||
if isinstance(packet, dict):
|
||||
attach_full_data_interpretation(packet)
|
||||
focus = opening_focus_block(packet)
|
||||
else:
|
||||
focus = ""
|
||||
language = _pl9_report_language(packet if isinstance(packet, dict) else {})
|
||||
if language == "en":
|
||||
structured_appendix = re.sub(
|
||||
r"^#\s+.*?(?:\n{2,}|\n)",
|
||||
"",
|
||||
@@ -2127,7 +2192,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
).strip()
|
||||
english_markdown = render_pl9_parity_markdown(packet)
|
||||
english_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus)
|
||||
if structured_appendix:
|
||||
english_markdown = (
|
||||
english_markdown.rstrip()
|
||||
@@ -2140,7 +2205,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
english_markdown,
|
||||
_pl9_english_report_title(packet),
|
||||
)
|
||||
return _apply_local_report_conventions(rendered)
|
||||
return _apply_local_report_conventions(rendered, language="en")
|
||||
zh_structured_markdown = _sanitize_pl9_ai_density_markdown(
|
||||
_render_pl9_ai_density_raw_data_markdown_en(packet),
|
||||
_pl9_public_report_title(packet),
|
||||
@@ -2152,7 +2217,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
).strip()
|
||||
zh_markdown = render_pl9_parity_markdown(packet)
|
||||
zh_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus)
|
||||
if zh_structured_appendix and "## 结构化资料附录" not in zh_markdown:
|
||||
zh_markdown = (
|
||||
zh_markdown.rstrip()
|
||||
@@ -2162,7 +2227,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
+ "\n\n</details>\n"
|
||||
)
|
||||
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
|
||||
return _apply_local_report_conventions(rendered)
|
||||
return _apply_local_report_conventions(rendered, language="zh")
|
||||
|
||||
|
||||
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -297,6 +297,8 @@ def normalize_zh_markdown_terms(markdown: str) -> str:
|
||||
text = _normalize_zh_bhava_factor_text(text)
|
||||
text = text.replace("Bhava 年龄状态 十二宫力量", "Bhava Bala 十二宫力量")
|
||||
text = text.replace("Shadbala / Bhava 年龄状态", "Shadbala / Bhava Bala")
|
||||
text = text.replace("六维力量 / Bhava 年龄状态", "六维力量 / Bhava Bala")
|
||||
text = text.replace("行星s", "行星")
|
||||
text = text.replace("星宿 Scheme", "星宿身体对应表")
|
||||
text = _normalize_mula_gloss(_restore_zh_nakshatra_glosses(_collapse_duplicate_zh_parentheses(text)))
|
||||
text = text.replace("### 是 Solar Return", "### 真实太阳返照")
|
||||
@@ -559,6 +561,180 @@ def _normalize_zh_dasha_prose(markdown: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# Fragments left after the longer English glossary. Longer keys are applied first.
|
||||
_EN_RESIDUAL_HAN: tuple[tuple[str, str], ...] = (
|
||||
("没有候选以吉相位照年盘", "no candidate aspects the annual Lagna by a benefic aspect"),
|
||||
("按子运起点星座奇偶", "by the odd or even sign where the sub-period starts"),
|
||||
("按七级比较所落星座", "compare the occupied signs across seven levels"),
|
||||
("两主分落别的星座", "the two lords fall in other signs"),
|
||||
("星座或其主所落星座", "the sign, or the sign its lord occupies"),
|
||||
("实际位置判断是否", "judge from the actual position whether"),
|
||||
("以吉相位照年盘", "aspect the annual Lagna by a benefic aspect"),
|
||||
("不单独增加结论的确定性", "does not by itself make a conclusion more certain"),
|
||||
("年数数到该", "the year count reaches that sign"),
|
||||
("一律顺行", "always direct"),
|
||||
("只倒算计都", "only Ketu is counted backward"),
|
||||
("未进打分", "not used in the score"),
|
||||
("时间轴结论", "timeline conclusion"),
|
||||
("主三角色", "primary triangle role"),
|
||||
("七轮裁定", "seven-round decision"),
|
||||
("书内分歧", "the book itself disagrees"),
|
||||
("待替换为", "still to be replaced by"),
|
||||
("取为年主", "is taken as the year lord"),
|
||||
("五分力量", "five-fold strength"),
|
||||
("五类候选", "five candidate groups"),
|
||||
("以凶相位照", "aspect by a malefic aspect"),
|
||||
("阶段框架", "phase frame"),
|
||||
("起始年龄", "start age"),
|
||||
("结束年龄", "end age"),
|
||||
("六重力量", "six-fold strength"),
|
||||
("矩阵源数据", "matrix source"),
|
||||
("相位矩阵", "aspect matrix"),
|
||||
("灵魂使命", "soul aim"),
|
||||
("核心自我", "core self"),
|
||||
("人生最高目标", "highest aim"),
|
||||
("主要谋士", "main advisor"),
|
||||
("权力代理", "delegated authority"),
|
||||
("兄弟姐妹", "siblings"),
|
||||
("冒险精神", "appetite for risk"),
|
||||
("家庭根基", "home base"),
|
||||
("情感安全感", "emotional security"),
|
||||
("祖先业力", "ancestral pattern"),
|
||||
("传统传承", "inherited tradition"),
|
||||
("智能成果", "crafted results"),
|
||||
("竞争对手", "rivals"),
|
||||
("转化力量", "capacity for change"),
|
||||
("配偶特质", "spouse traits"),
|
||||
("伴侣关系", "partnership"),
|
||||
("书例校准", "book-example calibration"),
|
||||
("八分法", "eightfold division"),
|
||||
("要素数量", "factor count"),
|
||||
("要素总数", "factor total"),
|
||||
("综合性质", "overall nature"),
|
||||
("基本年数", "base years"),
|
||||
("并列参考", "side reference"),
|
||||
("双主星", "dual lords"),
|
||||
("子运方向", "sub-period direction"),
|
||||
("身份按同", "identity follows the same"),
|
||||
("明显迹象", "a clear indication"),
|
||||
("严重程度", "severity"),
|
||||
("轴线之间", "between the axis"),
|
||||
("颗行星", "planets"),
|
||||
("高峰期", "peak phase"),
|
||||
("下降期", "setting phase"),
|
||||
("最困难", "most difficult"),
|
||||
("需结合", "read together with"),
|
||||
("灵魂星", "soul planet"),
|
||||
("兄弟星", "sibling planet"),
|
||||
("母亲星", "mother planet"),
|
||||
("父亲星", "father planet"),
|
||||
("子女星", "child planet"),
|
||||
("障碍星", "obstacle planet"),
|
||||
("配偶星", "spouse planet"),
|
||||
("旧算法", "previous algorithm"),
|
||||
("按原书", "per the book"),
|
||||
("起运", "period start"),
|
||||
("从主", "from the lord"),
|
||||
("级取", "the level selects"),
|
||||
("两主", "two lords"),
|
||||
("等分", "equal parts"),
|
||||
("未采用", "not used"),
|
||||
("星座奇偶", "odd or even sign"),
|
||||
("的子运", "sub-period"),
|
||||
("待评估", "not yet assessed"),
|
||||
("进行medium", "underway"),
|
||||
("当before", "current "),
|
||||
("原始值", "raw value"),
|
||||
("主层", "main level"),
|
||||
("子层", "sub level"),
|
||||
("年数", "years"),
|
||||
("主星", "lord"),
|
||||
("序号", "index"),
|
||||
("起始", "start"),
|
||||
("结束", "end"),
|
||||
("星座", "sign"),
|
||||
("相位", "aspect"),
|
||||
("年度", "annual"),
|
||||
("细项", "detail"),
|
||||
("年返", "solar return"),
|
||||
("摘要", "summary"),
|
||||
("项目", "item"),
|
||||
("数值", "value"),
|
||||
("方法", "method"),
|
||||
("校验", "check"),
|
||||
("辅助", "supporting"),
|
||||
("算法", "algorithm"),
|
||||
("并列", "listed alongside"),
|
||||
("顺序", "order"),
|
||||
("修正", "adjustment"),
|
||||
("最终", "final"),
|
||||
("正文", "body text"),
|
||||
("阶段", "phase"),
|
||||
("是否", "whether"),
|
||||
("只有", "only"),
|
||||
("特殊", "special"),
|
||||
("方向", "direction"),
|
||||
("勇气", "courage"),
|
||||
("母亲", "mother"),
|
||||
("父亲", "father"),
|
||||
("子女", "children"),
|
||||
("创造力", "creativity"),
|
||||
("学生", "students"),
|
||||
("障碍", "obstacles"),
|
||||
("质量", "quality"),
|
||||
("顺行", "direct"),
|
||||
("逆行", "retrograde"),
|
||||
("比较", "compare"),
|
||||
("另一", "another"),
|
||||
("部分", "partial"),
|
||||
("进行", "in progress"),
|
||||
("占据planet尊贵", "occupied planet dignity"),
|
||||
("占据planet数", "occupied planet count"),
|
||||
("partial减weak", "partially reduced"),
|
||||
("成立条件", "conditions"),
|
||||
("不in煞", "not in a malefic house"),
|
||||
("迹象", "indication"),
|
||||
("相邻", "adjacent"),
|
||||
("占据", "occupies"),
|
||||
("尊贵", "dignity"),
|
||||
("克", "restrains"),
|
||||
("其", " its "),
|
||||
("减", "reduced"),
|
||||
("最", "most "),
|
||||
("条", "items"),
|
||||
("的", " of "),
|
||||
("不", "not "),
|
||||
("吉", "benefic"),
|
||||
("数", "count"),
|
||||
("煞", "malefic"),
|
||||
("看", "see"),
|
||||
("起", "from"),
|
||||
("版", "edition"),
|
||||
("级", "level"),
|
||||
("主", "lord"),
|
||||
("期", "phase"),
|
||||
("星", "planet"),
|
||||
("度", "degree"),
|
||||
("从", "from"),
|
||||
("取", "take"),
|
||||
("所", "at"),
|
||||
("较", "comparatively"),
|
||||
("当", "when"),
|
||||
("按", "by"),
|
||||
("有", "present"),
|
||||
("无", "absent"),
|
||||
("表", "table"),
|
||||
)
|
||||
|
||||
|
||||
def apply_en_residual_han(markdown: str) -> str:
|
||||
"""Translate Han fragments that survive the longer English glossary."""
|
||||
text = markdown
|
||||
for raw, public in sorted(_EN_RESIDUAL_HAN, key=lambda item: len(item[0]), reverse=True):
|
||||
text = text.replace(raw, public)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_en_markdown_terms(markdown: str) -> str:
|
||||
text = markdown.replace("(", "(").replace(")", ")").replace("、", ", ")
|
||||
text = (
|
||||
@@ -576,7 +752,7 @@ def normalize_en_markdown_terms(markdown: str) -> str:
|
||||
r"\1 \2",
|
||||
text,
|
||||
)
|
||||
return _thicken_en_dasha_density(text)
|
||||
return apply_en_residual_han(_thicken_en_dasha_density(text))
|
||||
|
||||
|
||||
def _thicken_en_dasha_density(markdown: str) -> str:
|
||||
|
||||
+140
-34
@@ -634,7 +634,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
return ';'.join(f"{key}={value}" for key, value in counts.items())
|
||||
|
||||
def _public_status_note(value) -> str:
|
||||
mapping = {
|
||||
zh_mapping = {
|
||||
'partial_verified': '已生成',
|
||||
'parameter_sensitive': '参数敏感',
|
||||
'blocked': '需补资料',
|
||||
@@ -647,6 +647,20 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
'blocked_external_callable': '外部回放待校对',
|
||||
'superseded_by_annual_tajika_pack.lifetime_annual_chart_pages': '由生命周期图盘页承接',
|
||||
}
|
||||
en_mapping = {
|
||||
'partial_verified': 'listed',
|
||||
'parameter_sensitive': 'see the listed parameters',
|
||||
'blocked': 'not supplied',
|
||||
'verified': 'checked',
|
||||
'match': 'matches',
|
||||
'mismatch': 'needs review',
|
||||
'not_applicable': 'not applicable',
|
||||
'pyjhora_behavior_only': 'external behavior note',
|
||||
'pyjhora_behavior_only / not_multiengine_parity': 'external behavior note',
|
||||
'blocked_external_callable': 'external replay still to review',
|
||||
'superseded_by_annual_tajika_pack.lifetime_annual_chart_pages': 'carried by the lifetime annual pages',
|
||||
}
|
||||
mapping = en_mapping if _lang == 'en' else zh_mapping
|
||||
text = str(value or '').strip()
|
||||
return mapping.get(text, text or '-')
|
||||
|
||||
@@ -827,7 +841,11 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
)
|
||||
lines.extend([
|
||||
'',
|
||||
'星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。',
|
||||
(
|
||||
'Sign lord, star lord, sub lord and sub-sub lord name the ruler of the planet sign, nakshatra and KP subdivision. Strength ratio compares Shadbala with the required strength. A ratio of 1 or more meets it.'
|
||||
if _lang == 'en'
|
||||
else '星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。'
|
||||
),
|
||||
'',
|
||||
])
|
||||
moon_chart = d1_sheet.get('moon_chart') if isinstance(d1_sheet.get('moon_chart'), dict) else {}
|
||||
@@ -1105,7 +1123,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
graha = advanced.get('graha_interpretation_pack') if isinstance(advanced.get('graha_interpretation_pack'), dict) else {}
|
||||
pages = graha.get('planet_pages') if isinstance(graha.get('planet_pages'), list) else []
|
||||
if pages:
|
||||
lines.extend(['## 行星、星宿与宫位解释', ''])
|
||||
lines.extend(['## Planets, Nakshatras and Houses' if _lang == 'en' else '## 行星、星宿与宫位解释', ''])
|
||||
for page in pages:
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
@@ -1124,10 +1142,18 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
shadbala_planets = _dict(shadbala.get('planets'))
|
||||
if shadbala_planets:
|
||||
lines.extend([
|
||||
'### p31-p33 Shadbala / Bhava Bala 摘要',
|
||||
'### Shadbala / Bhava Bala Summary' if _lang == 'en' else '### p31-p33 Shadbala / Bhava Bala 摘要',
|
||||
'',
|
||||
f"- Shadbala 方法:{_cell(shadbala.get('method'))}",
|
||||
f"- 外部边界:{_cell(shadbala.get('external_parity_boundary'))}",
|
||||
(
|
||||
f"- Shadbala method: {_cell(shadbala.get('method'))}"
|
||||
if _lang == 'en'
|
||||
else f"- Shadbala 方法:{_cell(shadbala.get('method'))}"
|
||||
),
|
||||
(
|
||||
f"- External boundary: {_cell(shadbala.get('external_parity_boundary'))}"
|
||||
if _lang == 'en'
|
||||
else f"- 外部边界:{_cell(shadbala.get('external_parity_boundary'))}"
|
||||
),
|
||||
'',
|
||||
'| Planet | Sthana | Dig | Kala | Chesta | Naisargika | Drik | Total | Required | BPHS minimum | Site band |',
|
||||
'|--------|--------|-----|------|--------|------------|------|-------|----------|--------------|-----------|',
|
||||
@@ -1202,7 +1228,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
# BUG-1210 (甲13): the formal three components (scripts/bhava_bala.py);
|
||||
# the local +2 / -1.5 house score is never shown.
|
||||
lines.extend([
|
||||
'### p32 Bhava Bala 十二宫力量',
|
||||
'### Bhava Bala of the twelve houses' if _lang == 'en' else '### p32 Bhava Bala 十二宫力量',
|
||||
'',
|
||||
'| House | Sign | Lord | Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa) |',
|
||||
'|-------|------|------|--------------|-----|---------|----------------|--------------|',
|
||||
@@ -1613,9 +1639,13 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
lines.extend([
|
||||
'### p62-p74 Vimshottari Dasha',
|
||||
'',
|
||||
f"- Current Dasha:{_token(current.get('lord'))}({_cell(current.get('start'))} - {_cell(current.get('end'))})" if current else '- Current Dasha:blocked',
|
||||
(
|
||||
f"- Current Dasha: {_token(current.get('lord'))} ({_cell(current.get('start'))} - {_cell(current.get('end'))})"
|
||||
if _lang == 'en'
|
||||
else f"- Current Dasha:{_token(current.get('lord'))}({_cell(current.get('start'))} - {_cell(current.get('end'))})"
|
||||
) if current else ('- Current Dasha: not supplied' if _lang == 'en' else '- Current Dasha:未列出'),
|
||||
'',
|
||||
'| 序号 | 主运 | 起始 | 结束 | 年数 |',
|
||||
'| Index | Maha | Start | End | Years |' if _lang == 'en' else '| 序号 | 主运 | 起始 | 结束 | 年数 |',
|
||||
'|------|------|------|------|------|',
|
||||
])
|
||||
for index, period in enumerate(vimshottari_timeline, start=1):
|
||||
@@ -1640,11 +1670,11 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
if isinstance(pd_period, dict):
|
||||
pd_rows.append((md_lord, ad_lord, pd_period))
|
||||
if ad_rows:
|
||||
lines.extend(['', '| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |', '|--------------|------|------|------|------|'])
|
||||
lines.extend(['', '| Vimshottari AD | Maha | Antar | Start | End |' if _lang == 'en' else '| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |', '|--------------|------|------|------|------|'])
|
||||
for index, (md_lord, period) in enumerate(ad_rows, start=1):
|
||||
lines.append(f"| {index} | {_cell(_token(md_lord))} | {_cell(_token(period.get('lord')))} | {_period_start(period)} | {_period_end(period)} |")
|
||||
if pd_rows:
|
||||
lines.extend(['', '| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |', '|--------------|------|------|--------|------|------|'])
|
||||
lines.extend(['', '| Vimshottari PD | Maha | Antar | Pratyantar | Start | End |' if _lang == 'en' else '| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |', '|--------------|------|------|--------|------|------|'])
|
||||
for index, (md_lord, ad_lord, period) in enumerate(pd_rows, start=1):
|
||||
lines.append(
|
||||
f"| {index} | {_cell(_token(md_lord))} | {_cell(_token(ad_lord))} | {_cell(_token(period.get('lord')))} | "
|
||||
@@ -1743,7 +1773,16 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
legacy_line = f'- 算法:{LEGACY_NOTE_ZH}'
|
||||
if legacy_line not in lines:
|
||||
lines.extend(['### Narayana Dasha', '', legacy_line, ''])
|
||||
rath_lines = [line for line in _rath_lines(narayana_packet.get('rath')) if line]
|
||||
rath_view = narayana_packet.get('rath')
|
||||
rath_lines = [line for line in _rath_lines(rath_view) if line]
|
||||
if _lang == 'en' and isinstance(rath_view, dict):
|
||||
for row in _list(rath_view.get('mahadasha')):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
zh_basis = row.get('dual_lord_basis')
|
||||
en_basis = row.get('dual_lord_basis_en')
|
||||
if zh_basis and en_basis:
|
||||
rath_lines = [line.replace(str(zh_basis), str(en_basis)) for line in rath_lines]
|
||||
if rath_lines and not any(str(line).startswith('- 并列:') for line in lines):
|
||||
lines.extend(rath_lines)
|
||||
lines.append('')
|
||||
@@ -1932,17 +1971,28 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
lines.append('')
|
||||
annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {}
|
||||
if annual:
|
||||
lines.extend(['## 年度图盘与 Tajika(PL9 p126-p155 对标)', ''])
|
||||
lines.extend([
|
||||
'## Annual Varshaphala and Tajika' if _lang == 'en' else '## 年度图盘与 Tajika(PL9 p126-p155 对标)',
|
||||
'',
|
||||
])
|
||||
annual_chart = _dict(annual.get('annual_chart'))
|
||||
annual_chart_data = _dict(annual_chart.get('data'))
|
||||
solar_return = _dict(annual.get('solar_return'))
|
||||
solar_return_data = _dict(solar_return.get('data') or solar_return)
|
||||
if solar_return_data or annual_chart_data:
|
||||
lines.extend([
|
||||
'### p126-p127 Varshaphala 基础资料与年度行星表',
|
||||
'### Varshaphala base data and annual planets' if _lang == 'en' else '### p126-p127 Varshaphala 基础资料与年度行星表',
|
||||
'',
|
||||
f"- Varshapravesha:{solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}",
|
||||
f"- 年返上升:{_token(annual_chart_data.get('asc_sign'))}",
|
||||
(
|
||||
f"- Varshapravesha: {solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}"
|
||||
if _lang == 'en'
|
||||
else f"- Varshapravesha:{solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}"
|
||||
),
|
||||
(
|
||||
f"- Solar-return Lagna: {_token(annual_chart_data.get('asc_sign'))}"
|
||||
if _lang == 'en'
|
||||
else f"- 年返上升:{_token(annual_chart_data.get('asc_sign'))}"
|
||||
),
|
||||
'',
|
||||
])
|
||||
year_lord = _dict(annual.get('year_lord'))
|
||||
@@ -2041,8 +2091,8 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
lines.extend([
|
||||
'### p132-p135 Mudda / Patyayini 年度 Dasha 表',
|
||||
'',
|
||||
'| balance profile | 主星 | 开始 | 结束 | 边界口径 |',
|
||||
'|-----------------|------|------|------|----------|',
|
||||
'| Balance profile | Lord | Start | End | Boundary |' if _lang == 'en' else '| balance profile | 主星 | 开始 | 结束 | 边界口径 |',
|
||||
'|-----------------|------|-------|-----|----------|' if _lang == 'en' else '|-----------------|------|------|------|----------|',
|
||||
])
|
||||
for row in mudda_rows:
|
||||
if not isinstance(row, dict):
|
||||
@@ -2068,13 +2118,34 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
f"{_cell(row.get('boundary'))} | {_public_status_note(row.get('boundary_status'))} |"
|
||||
)
|
||||
lines.append('')
|
||||
if not patyayini_rows:
|
||||
lines.extend([
|
||||
'Patyayini is not implemented in this report.' if _lang == 'en' else 'Patyayini 没有实现。',
|
||||
'',
|
||||
])
|
||||
monthly_windows = _list(annual.get('monthly_windows'))
|
||||
if monthly_windows:
|
||||
lines.extend([
|
||||
'### Monthly mudda windows' if _lang == 'en' else '### Mudda 月段窗口',
|
||||
'',
|
||||
'| Index | Lord | Months | Start | End |' if _lang == 'en' else '| 序号 | 主星 | 月数 | 开始 | 结束 |',
|
||||
'|-------|------|--------|-------|-----|' if _lang == 'en' else '|------|------|------|------|------|',
|
||||
])
|
||||
for row in monthly_windows:
|
||||
if not isinstance(row, dict):
|
||||
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'))} |"
|
||||
)
|
||||
lines.append('')
|
||||
tajika_rows = _list(normalized.get('tajika_yoga_rows')) or _list(normalized.get('pyjhora_tajika_yoga_rows'))
|
||||
if tajika_rows:
|
||||
lines.extend([
|
||||
'### p136-p138 Tajika Yoga Presence 对照表',
|
||||
'',
|
||||
'| Yoga | 结果 | 形成行星 | 相位运动(入相/分离) |',
|
||||
'|------|------|----------|----------------------|',
|
||||
'| Yoga | Result | Forming planets | Motion (applying/separating) |' if _lang == 'en' else '| Yoga | 结果 | 形成行星 | 相位运动(入相/分离) |',
|
||||
'|------|--------|-----------------|--------------------------------|' if _lang == 'en' else '|------|------|----------|----------------------|',
|
||||
])
|
||||
for row in tajika_rows[:32]:
|
||||
if not isinstance(row, dict):
|
||||
@@ -2088,10 +2159,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
saham_rows = _list(normalized.get('saham_rows'))
|
||||
if saham_rows:
|
||||
lines.extend([
|
||||
'### p130 Sahams 点位表',
|
||||
'### Sahams' if _lang == 'en' else '### p130 Sahams 点位表',
|
||||
'',
|
||||
'| Saham | 星座 | 星座内度数 |',
|
||||
'|-------|------|------------|',
|
||||
'| Saham | Sign | Degree in sign |' if _lang == 'en' else '| Saham | 星座 | 星座内度数 |',
|
||||
'|-------|------|----------------|' if _lang == 'en' else '|-------|------|------------|',
|
||||
])
|
||||
for row in saham_rows[:48]:
|
||||
if not isinstance(row, dict):
|
||||
@@ -2442,11 +2513,16 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
detected_yogas = _list(advanced_yoga.get('detected_yogas')) or _list(advanced_yoga.get('yogas'))
|
||||
dosha_pack = _dict(advanced.get('dosha_remedy_pack'))
|
||||
bhavesh_pack = _dict(advanced.get('bhavesh_interpretation_pack'))
|
||||
if d1_houses or yogas_doshas or detected_yogas or bhavesh_pack or dosha_pack:
|
||||
lines.extend(['## Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)', ''])
|
||||
yoga_pack = _dict(advanced.get('yoga_interpretation_pack'))
|
||||
yoga_pages = _list(yoga_pack.get('pages'))
|
||||
if d1_houses or yogas_doshas or detected_yogas or bhavesh_pack or dosha_pack or yoga_pages:
|
||||
lines.extend([
|
||||
'## Bhavesh, Dosha and Yoga' if _lang == 'en' else '## Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)',
|
||||
'',
|
||||
])
|
||||
if d1_houses and d1_planets:
|
||||
lines.extend([
|
||||
'### p156-p162 宫主(Bhavesh)落宫解释',
|
||||
'### House lords (Bhavesh)' if _lang == 'en' else '### p156-p162 宫主(Bhavesh)落宫解释',
|
||||
'',
|
||||
'| House | Sign | Lord | Lord placement | Lord dignity | Dasha activation |',
|
||||
'|-------|------|------|----------------|--------------|------------------|',
|
||||
@@ -2473,14 +2549,18 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
bhavesh_pages = _list(bhavesh_pack.get('pages'))
|
||||
if bhavesh_pages:
|
||||
lines.extend([
|
||||
'### Bhavesh 宫主逐宫解释',
|
||||
'### House-lord interpretation' if _lang == 'en' else '### Bhavesh 宫主逐宫解释',
|
||||
'',
|
||||
])
|
||||
for row in bhavesh_pages:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sections = _dict(row.get('sections'))
|
||||
title = f"第{row.get('house')}宫主:{_token(row.get('lord'))}"
|
||||
title = (
|
||||
f"House {row.get('house')} lord: {_token(row.get('lord'))}"
|
||||
if _lang == 'en'
|
||||
else f"第{row.get('house')}宫主:{_token(row.get('lord'))}"
|
||||
)
|
||||
lines.extend([f'#### {title}', ''])
|
||||
for key in ('house_context', 'lord_placement', 'result', 'context', 'activation'):
|
||||
value = str(sections.get(key) or '').strip()
|
||||
@@ -2516,7 +2596,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
dosha_rows = _list(dosha_pack.get('dosha_rows'))
|
||||
if dosha_rows:
|
||||
lines.extend([
|
||||
'### Dosha 逐项事实与解释',
|
||||
'### Dosha facts and interpretation' if _lang == 'en' else '### Dosha 逐项事实与解释',
|
||||
'',
|
||||
])
|
||||
for row in dosha_rows:
|
||||
@@ -2535,7 +2615,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
recommendations = _list(practices.get('recommendations'))
|
||||
if recommendations:
|
||||
lines.extend([
|
||||
'### 传统辅助建议',
|
||||
'### Traditional supportive notes' if _lang == 'en' else '### 传统辅助建议',
|
||||
'',
|
||||
'| 对象 | 建议 | 宝石/次数 |',
|
||||
'|------|------|-----------|',
|
||||
@@ -2587,13 +2667,39 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
'|------|----------|----------|-------------|-----------------|',
|
||||
])
|
||||
for row in group_rows:
|
||||
effects = row.get('effects') if isinstance(row.get('effects'), list) else []
|
||||
if _lang == 'en':
|
||||
effects = row.get('effects_en') if isinstance(row.get('effects_en'), list) else []
|
||||
if not effects and isinstance(row.get('effects'), list):
|
||||
effects = row.get('effects')
|
||||
yoga_name = row.get('name') or row.get('type') or row.get('name_cn')
|
||||
combination = row.get('combination_en') or row.get('combination') or row.get('source_reference') or row.get('reason')
|
||||
else:
|
||||
effects = row.get('effects') if isinstance(row.get('effects'), list) else []
|
||||
yoga_name = row.get('name_cn') or row.get('name') or row.get('type')
|
||||
combination = row.get('combination') or row.get('source_reference') or row.get('reason')
|
||||
lines.append(
|
||||
f"| {_cell(row.get('name_cn') or row.get('name') or row.get('type'))} | {_cell(row.get('category') or row.get('subtype'))} | "
|
||||
f"{_cell(row.get('strength'))} | {_cell(row.get('combination') or row.get('source_reference') or row.get('reason'))} | "
|
||||
f"| {_cell(yoga_name)} | {_cell(row.get('category') or row.get('subtype'))} | "
|
||||
f"{_cell(row.get('strength'))} | {_cell(combination)} | "
|
||||
f"{_cell(_public_report_text(_brief(effects, 3) or row.get('interpretation') or row.get('summary')))} |"
|
||||
)
|
||||
lines.append('')
|
||||
if yoga_pages:
|
||||
lines.extend([
|
||||
'### Yoga interpretation' if _lang == 'en' else '### 瑜伽解释',
|
||||
'',
|
||||
])
|
||||
for row in yoga_pages:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
name = str(row.get('name') or '').strip()
|
||||
sections = _dict(row.get('sections'))
|
||||
if not name:
|
||||
continue
|
||||
lines.extend([f'#### {name}', ''])
|
||||
for key in ('detected', 'theme', 'result', 'activation'):
|
||||
value = str(sections.get(key) or '').strip()
|
||||
if value:
|
||||
lines.extend([_public_report_text(value), ''])
|
||||
supportive_raw = dosha_pack.get('supportive_practices')
|
||||
supportive_practices = _list(supportive_raw)
|
||||
if isinstance(supportive_raw, dict):
|
||||
@@ -2620,7 +2726,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
dasha = timing.get('dasha_interpretation_pack') if isinstance(timing.get('dasha_interpretation_pack'), dict) else {}
|
||||
dasha_pages = dasha.get('page_series') if isinstance(dasha.get('page_series'), list) else []
|
||||
if dasha_pages:
|
||||
lines.extend(['## 大运解释', ''])
|
||||
lines.extend(['## Dasha Interpretation' if _lang == 'en' else '## 大运解释', ''])
|
||||
for page in dasha_pages:
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
|
||||
@@ -82,14 +82,11 @@ def test_chinese_edition_is_untouched_by_the_english_render(rendered) -> None:
|
||||
|
||||
def test_english_edition_has_no_chinese(rendered) -> None:
|
||||
_, _, en, _ = rendered
|
||||
# 原值:阅读版英文 han_leaks 为空
|
||||
# 新值:完整数据版英文若仍含汉字,必须是清理器留下的 source text retained 标记;清干净时汉字为空
|
||||
# 原因:BUG-1272。公开接口整份撤回英文,不把夹中文的英文交给用户;见 API 测试
|
||||
if HAN.search(en):
|
||||
assert "[source text retained:" in en
|
||||
else:
|
||||
assert han_leaks(en) == []
|
||||
assert not HAN.search(en)
|
||||
# 原值:有汉字就必须是 source text retained
|
||||
# 新值:英文全文汉字数为 0
|
||||
# 原因:BUG-1272。术语补齐后,这三张虚构盘不得再留下汉字
|
||||
assert re.findall(r"[㐀-鿿豈-]+", en) == []
|
||||
assert han_leaks(en) == []
|
||||
|
||||
|
||||
def test_english_edition_passes_reader_leak_patterns(rendered) -> None:
|
||||
@@ -166,16 +163,13 @@ def test_api_without_languages_is_unchanged_and_with_languages_adds_english() ->
|
||||
assert both["markdown"] == plain["markdown"]
|
||||
assert both["fact_table_packet"] == plain["fact_table_packet"]
|
||||
assert both["reader_dasha_applicability"] == plain["reader_dasha_applicability"]
|
||||
# 原值:阅读版一定带回无汉字的 markdown_en
|
||||
# 新值:英文要么整份无汉字,要么 english_unavailable=han_leak 且不带 markdown_en
|
||||
# 原因:BUG-1272,完整数据版英文清理后仍有汉字时走既有「英文暂不可用」
|
||||
if "markdown_en" in both:
|
||||
assert not HAN.search(both["markdown_en"])
|
||||
assert not HAN.search(both.get("reader_dasha_applicability_en") or "")
|
||||
assert "english_unavailable" not in both
|
||||
else:
|
||||
assert both["english_unavailable"] == "han_leak"
|
||||
assert "markdown_en" not in both
|
||||
# 原值:英文要么整份无汉字,要么 english_unavailable=han_leak 且不带 markdown_en
|
||||
# 新值:虚构日盘必须带回无汉字的 markdown_en
|
||||
# 原因:BUG-1272。这张盘英文已清零,接口应交付英文;人为泄漏仍由下一条测试撤回
|
||||
assert "markdown_en" in both
|
||||
assert not HAN.search(both["markdown_en"])
|
||||
assert not HAN.search(both.get("reader_dasha_applicability_en") or "")
|
||||
assert "english_unavailable" not in both
|
||||
zh_only = _api_response(packet, {**base_body, "languages": ["zh"]})
|
||||
assert json.dumps(zh_only, sort_keys=True, ensure_ascii=False) == json.dumps(plain, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Report completion for the full-data edition (TASK-report-full-data-fix-20261008).
|
||||
|
||||
Template wording, annual field projection, the opening focus, and the two
|
||||
mistranslations. Chart numbers come from the packet. These tests do not
|
||||
calculate a new dasha or import PyJHora.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
|
||||
from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown
|
||||
from scripts.pl9_interpretation_packs import (
|
||||
attach_full_data_interpretation,
|
||||
missing_field_count,
|
||||
opening_focus_block,
|
||||
section_coverage,
|
||||
)
|
||||
from scripts.pl9_reader_english import HAN
|
||||
|
||||
FORBIDDEN_ZH = (
|
||||
"再婚",
|
||||
"几段关系",
|
||||
"早夭",
|
||||
"一定会",
|
||||
"必然",
|
||||
"Neecha Bhanga Raja Yoga",
|
||||
"落陷取消王",
|
||||
"疾病诊断",
|
||||
"疾病",
|
||||
)
|
||||
FORBIDDEN_EN = (
|
||||
"divorce",
|
||||
"miscarriage",
|
||||
"infertility",
|
||||
"guarantee",
|
||||
"guaranteed",
|
||||
)
|
||||
HALF_TRANSLATED = re.compile(r"[A-Za-z]+\s*[\u4e00-\u9fff]+s")
|
||||
|
||||
|
||||
def _planet(sign: str, house: int, nakshatra: str, status: str = "Own Sign") -> dict:
|
||||
return {"sign": sign, "house": house, "status": status, "nakshatra": nakshatra}
|
||||
|
||||
|
||||
def _packet(language: str = "zh") -> dict:
|
||||
planets = {
|
||||
"Sun": _planet("Aries", 10, "Ashwini", "入旺(Exalted)"),
|
||||
"Moon": _planet("Taurus", 2, "Rohini"),
|
||||
"Mars": _planet("Capricorn", 7, "Exalted"),
|
||||
"Mercury": _planet("Pisces", 6, "Debilitated"),
|
||||
"Jupiter": _planet("Cancer", 1, "Exalted"),
|
||||
"Venus": _planet("Libra", 4, "Own Sign"),
|
||||
"Saturn": _planet("Aquarius", 8, "Own Sign"),
|
||||
"Rahu": _planet("Gemini", 12, "Ardra", ""),
|
||||
"Ketu": _planet("Sagittarius", 6, "Mula", ""),
|
||||
}
|
||||
lords = ["Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter"]
|
||||
houses = {f"house_{index}": {"lord": lord, "cusp_sign": "Aries"} for index, lord in enumerate(lords, start=1)}
|
||||
timeline = []
|
||||
for lord in ("Sun", "Moon", "Mars"):
|
||||
current = lord == "Moon"
|
||||
timeline.append({
|
||||
"lord": lord,
|
||||
"start": "2000-01-01",
|
||||
"end": "2006-01-01",
|
||||
"is_current": current,
|
||||
"antardasha_timeline": [{
|
||||
"lord": "Mars",
|
||||
"start": "2001-01-01",
|
||||
"end": "2002-01-01",
|
||||
"is_current": current,
|
||||
"pratyantar_dasha_timeline": [{
|
||||
"lord": "Mercury",
|
||||
"start": "2001-02-01",
|
||||
"end": "2001-04-01",
|
||||
"is_current": current,
|
||||
}],
|
||||
}],
|
||||
})
|
||||
return {
|
||||
"report_language": language,
|
||||
"birth_info": {"name": "虚构甲" if language == "zh" else "Fictional"},
|
||||
"worksheets": {
|
||||
"d1_rasi_bhava": {"planets": planets, "houses": houses, "ascendant": {"sign": "Cancer"}},
|
||||
"strengths_and_scores": {
|
||||
"functional_benefic_malefic": {
|
||||
"functional_benefics": ["Jupiter", "Mars"],
|
||||
"functional_malefics": ["Saturn"],
|
||||
"functional_neutrals": ["Moon"],
|
||||
},
|
||||
"shadbala": {"planets": {"Sun": {"total": 1.25}}},
|
||||
},
|
||||
"timing_and_predictive_systems": {
|
||||
"dasha": {"timeline": timeline, "current_dasha": timeline[1]},
|
||||
"annual_tajika_pack": {
|
||||
"solar_return": {"status": "partial_verified", "data": {"dt_local": "2026-04-07T09:00:00"}},
|
||||
"mudda_dasha": {
|
||||
"status": "partial_verified",
|
||||
"periods": [{"lord": "Sun", "start": "2026-04-07", "end": "2026-05-07", "months": 1}],
|
||||
},
|
||||
"patyayini_dasha": {"status": "blocked", "reason": "not_in_engine"},
|
||||
"sahams": {"status": "partial_verified", "data": [{"name": "Punya", "sign": "Aries", "degree_in_sign": 1.2}]},
|
||||
"tajika_yogas": {
|
||||
"status": "partial_verified",
|
||||
"data": {"yogas": [{"name": "Ithasala", "present": True, "planets": ["Sun", "Moon"]}]},
|
||||
},
|
||||
"monthly_windows": [{
|
||||
"index": 1,
|
||||
"lord": "Sun",
|
||||
"duration_months": 1,
|
||||
"start": "2026-04-07",
|
||||
"end": "2026-05-07",
|
||||
}],
|
||||
},
|
||||
},
|
||||
"advanced_systems": {
|
||||
"yoga": {
|
||||
"detected_yogas": [{
|
||||
"name": "Gajakesari Yoga",
|
||||
"name_cn": "象鹿瑜伽",
|
||||
"category": "raja",
|
||||
"effects": ["名声可见"],
|
||||
"effects_en": ["visible reputation"],
|
||||
"planets": ["Moon", "Jupiter"],
|
||||
"combination": "Moon and Jupiter",
|
||||
}, {
|
||||
"name": "Neecha Bhanga Raja Yoga",
|
||||
"name_cn": "落陷取消王瑜伽",
|
||||
"category": "neecha_bhanga",
|
||||
"effects": ["传统条件成立"],
|
||||
"effects_en": ["the cancellation conditions hold"],
|
||||
"planets": ["Mercury"],
|
||||
}],
|
||||
},
|
||||
"yogas_doshas": {"mangal_dosha": {"has_dosha": False, "mars_house": 7, "severity": "none"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_t5_repairs_bhava_bala_and_planets_suffix() -> None:
|
||||
raw = "\n".join([
|
||||
"# 印度占星星盘报告",
|
||||
"",
|
||||
"### p31-p33 Shadbala / Bhava Bala 摘要",
|
||||
"",
|
||||
"### p32 Bhava Bala 十二宫力量",
|
||||
"",
|
||||
"### p125 Natal / KP Ruling Planets",
|
||||
"",
|
||||
"### Upagraha and Sub-Planets Points",
|
||||
"",
|
||||
"### Planetary Friendship Matrix",
|
||||
"",
|
||||
])
|
||||
text = _sanitize_pl9_ai_density_markdown(raw)
|
||||
assert "Bhava 年龄状态" not in text
|
||||
assert "六维力量 / Bhava Bala" in text
|
||||
assert "Bhava Bala 十二宫力量" in text
|
||||
assert "行星s" not in text
|
||||
assert "本命 / KP 征象星" in text
|
||||
assert HALF_TRANSLATED.search(text) is None
|
||||
assert "年龄状态" not in text.split("Graha Avastha", 1)[-1] or "Baladi" not in raw
|
||||
|
||||
|
||||
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
|
||||
text = render_full_data_markdown(packet)
|
||||
for heading in ("大运解释", "行星、星宿与宫位解释", "宫主逐宫解释", "Dosha 逐项事实与解释", "瑜伽解释"):
|
||||
assert heading in text
|
||||
assert "Patyayini 没有实现" in text
|
||||
assert "Mudda 月段窗口" in text
|
||||
assert "blocked" not in text.lower()
|
||||
for phrase in FORBIDDEN_ZH:
|
||||
assert phrase not in text
|
||||
assert "落陷取消(Neecha Bhanga" in text
|
||||
assert "Raja Yoga" not in text
|
||||
assert HALF_TRANSLATED.search(text) is None
|
||||
assert "行星s" not in text
|
||||
coverage = {row["section"]: row["status"] for row in section_coverage(packet)}
|
||||
assert coverage["sthira"] == "out_of_scope"
|
||||
assert "Sthira Dasha" not in text
|
||||
assert missing_field_count(packet) <= 10
|
||||
|
||||
|
||||
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
|
||||
assert "AD" in text and "PD" in text
|
||||
assert "Dasha Interpretation" in text
|
||||
assert "House-lord interpretation" in text
|
||||
assert "Yoga interpretation" in text
|
||||
assert "Patyayini is not implemented" in text
|
||||
assert "Monthly mudda windows" in text
|
||||
for phrase in FORBIDDEN_EN:
|
||||
assert phrase not in text.lower()
|
||||
assert "Neecha Bhanga Raja Yoga" not in text
|
||||
assert "not a Raja Yoga" in text
|
||||
leaked = HAN.findall(text)
|
||||
assert leaked == [], leaked[:12]
|
||||
|
||||
|
||||
def test_chinese_render_stays_stable_when_english_uses_a_copy() -> None:
|
||||
packet = _packet("zh")
|
||||
before = render_full_data_markdown(copy.deepcopy(packet))
|
||||
english = copy.deepcopy(packet)
|
||||
english["report_language"] = "en"
|
||||
render_full_data_markdown(english)
|
||||
after = render_full_data_markdown(copy.deepcopy(packet))
|
||||
assert after == before
|
||||
Reference in New Issue
Block a user