Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) bccceff91f 明说要校正时,第一步就能打开(BUG-1317)
本命和申报时段的第 0 步,校正工具挂上时可以排盘,也可以直接打开校正。还没看盘就顺口提议不算已经提议过。规则第一句写明 explicitRequest=true。定向测试 149 通过,类型检查和 lint 通过,隐私标记通过。名人生平回测未跑。

Co-Authored-By: Grok (agent loop)
2026-10-13 22:02:41 +08:00
Jesse_ChenandClaude Opus 5.5 0c04755560 docs(tasks): rectification step-0 entry brief (agent loop, issue #54)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 21:45:42 +08:00
Jesse_ChenandClaude Opus 5.5 963276ca2f docs(tasks): report focus card brief (agent loop, issue #55)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 21:16:53 +08:00
Jesse_ChenandClaude Opus 5.5 856bc850ba tools(agent-loop): Claude merges a stale branch itself when only docs conflict
Product owner chose option A on Jyotisha #53 (2026-10-13): being behind
staging with a docs-only merge is not a bounce and does not count toward
the two-bounce escalation. Code conflicts still go back to Grok.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 18:49:03 +08:00
Claude 3cc7fe23d1 merge origin/staging into the report translation fix (issue #53)
Independent Staging Quality Gate / validate (push) Successful in 15m24s
Independent Staging Quality Gate / publish (push) Successful in 3m48s
Docs-only conflict in BUG_HISTORY: BUG-1311 (#52) and BUG-1312..1315 (#53)
both appended after BUG-1310; kept both in number order. Product owner
chose option A on #53: Claude merges a stale branch when only docs conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 18:44:38 +08:00
jesse-uxandGrok (agent loop) 830e5d0267 test(report): expect parenthesized karaka names in the full-data table
The Chinese full-data body prints karaka names with parentheses after the glued-phrase pass. The BPHS order test still expected the KARAKA_CN literals and failed the full pytest run. Expect translate_zh_glued_phrases of KARAKA_CN instead of editing KARAKA_CN.

Pytest of the karaka file, the six report contract files, and privacy markers: 154 passed. tsc exit 0. lint 0 errors, 125 warnings.

Co-Authored-By: Grok (agent loop)
2026-10-13 15:55:56 +08:00
jesse-ux 7242648e45 merge origin/staging into the report translation fix 2026-10-13 15:20:55 +08:00
jesse-uxandGrok (agent loop) a295afa40f fix(report): draw charts in the full-data body and clean Chinese residue
New reports draw one north-Indian chart for each ready divisional in the body, so the trailing duplicate section drops. Chinese full-data prose no longer glues English onto Han or leaves listed, background, or native. The birth table starts with the ascendant. The D9 same-sign column says yes or no.

Self-test: tsc exit 0; npm run lint exit 0 (0 errors, 125 existing warnings); three frontend test files 39 pass, 0 fail, 0 cancelled; pytest test_report_chart_blank_columns.py 16 passed; seven related report files 88 passed, exit 0; privacy markers 63 passed.

Co-Authored-By: Grok (agent loop)
2026-10-13 14:02:26 +08:00
31 changed files with 3999 additions and 46 deletions
+13
View File
@@ -1,5 +1,18 @@
# 印度占星 Skill 更新日志
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
- 问事业、感情这类普通问题,仍是先排盘再回答。只是觉得解读不准,仍是认下来改着读,不会被拉去做校正。
- Skill 版本不变(BUG-1317,是 BUG-1306 的复发)。
## 2026-10-13 — 新报告正文画出分盘,中文更干净,上升行和同位列能读懂(待合入 staging)
- 重新生成的报告里,「本命与分盘北印度图盘」每张有数据的盘下面有一张北印度图。同一张盘只画一次。报告最末不再重复「分盘核对」。旧报告打开仍是当时保存的正文。
- 中文报告正文去掉 listed、background、native、Custom condition met 这类英文,也不再把中文和英文粘成一个词。括号里的星宿、瑜伽名保留。
- 「本命盘行星原始位置」第一行是上升,星宿和 pada 有值。尊贵状态写「不适用」。没有可填的格子与其它空格一样写「未列」。
- 星盘页 D9 表的「同位」列:同座写「是」,不同座写「否」。其它分盘仍没有这一列。Skill 版本不变(BUG-1312~1315)。
## 2026-10-12 — 设置里的岁差说明改对:新开的生时校正也跟着设置走(已合入 staging)
- 设置页换岁差时的说明原来写「生时校正目前仍固定按 Raman」,与实际相反。改为:之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。Skill 版本不变(BUG-1310)。
+76
View File
@@ -17556,6 +17556,7 @@
- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。F3 另锁:结算记录是 `offered` 且 `autoOpen: true`、卡片在绑定前不说「已经开始」、绑定后才说已经开始、打开失败仍是 `offered`、旧的 `offered` / `opened` / `returned` 照常解析且 `returned` 不出卡。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
- 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。
- 复发自:无。
- 复发:BUG-1317(2026-10-13。直达已部署,但第 0 步仍只有排盘工具,模型排完盘后没有带 explicitRequest 打开校正)。
- 修复版本:staging `b8bdc003`(实现 `6a799582` 变基到 `9f1e8257`,代码逐字节相同;2026-10-12 Claude 验收通过、产品要求合入;部署核对:health gitCommit=b8bdc003、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401)。跟进项 F3(打开失败卡片仍说已开始)见 `TASK-rectification-1012-batch-fix-20261012`。 F3 跟进(`51a77721`)已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。
- 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。
@@ -17630,3 +17631,78 @@
- 相关记录:无。
- 复发自:无。
- 修复版本:分支 `codex/composer-first-glyph-clip-20261013`,尚未部署。推送后的 40 位 SHA 写在 issue #52 的执行回报里。
## BUG-1312 | 中文完整数据报告正文仍有英文功能词,并与汉字粘在一起
- 状态:fixed-pending-verify(只对新生成的报告生效;真机清单 `docs/testing/report-translation-gaps-20261013.md` 未走,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #53。任务书在 `941b06c19` 重跑虚构日盘:附录之前英文功能词 218 处、汉字与英文粘连 14 处。
- 现象:中文报告里出现 listed、background、native、Custom condition met,以及「木星Jupiter…」这种中英粘在一起的字。星宿、瑜伽名放在括号里是允许的。
- 根因:中文完整数据版是在英文原文上做短词替换。短词先换会把长句切碎;有的格子本身就是英文枚举,中文版原样印出。瑜伽组合在中文路径仍优先取了英文组合句。行星中文名已经带英文,庙旺又紧挨着拼上去,中间没有空格。
- 修复:中文路径先整句、长词,再短词。宫主状态换成「当前运行 / 背景」,listed 换成「已列出」,native 换成「本命」,自定义条件换成「满足自定义条件」。中文瑜伽用中文组合句。不改行星中文名本身,也不改八分法引擎文件;报告层把粘在一起的方法名改成带空格的读法。残留门只数附录之前的正文,图盘围栏不计入:汉字与至少 3 个拉丁字母直接粘连为 0,功能词 of/in/the/and/by/from/with/listed/background/native/condition/met/satisfied/occupied 为 0。括号里的梵文和表头缩写 SAV、BAV、KP、D1–D60 不在这两项里。
- 验证:故意写入「木星Jupiterexalted」时残留门为 fail。虚构日盘、虚构夜盘、一张公开名人盘(爱因斯坦)的中文完整数据版都是粘连 0、功能词 0。`tests/test_report_chart_blank_columns.py` 16 通过。打回第 1 次:报告星位表已印成「灵魂星(AK)」这类带括号的名字,`test_reader_report_karaka_table_is_in_bphs_order` 仍按 `KARAKA_CN` 原字面值比对,已改为经 `translate_zh_glued_phrases` 后再比对。`KARAKA_CN` 未改。
- 防复发:`_pl9_markdown_hygiene_receipt(language="zh")` 把上述两项写成违规。
- 相关记录:BUG-1313、BUG-1314。
- 复发自:无。
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
## BUG-1313 | 完整数据版「本命与分盘北印度图盘」只有空标题,正文不画图
- 状态:fixed-pending-verify(旧报告正文不变,须重新生成;真机清单未走,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #53。
- 现象:该节是一列盘名,没有北印度方形图。报告末尾因此仍出现第二遍「分盘核对」。
- 根因:完整数据版只写空的小标题,不写图盘围栏。10-07 个人报告改用完整数据版之后,BUG-1203「正文画全分盘、末尾整段消失」的前提没了。当时的测试只用读者版样例,没拦住这次切换。
- 修复:这一节里每张有星座可画的盘写一张北印度图,同一编号只画一次。去掉与九分盘重复的第二张,以及画不出来的空宫位标题。逆行跟本命。中英标题都有。末尾「分盘核对」仍用原来的去重,不新做折叠。旧样例保留,用来锁住「旧正文仍显示分盘核对」。
- 验证:虚构日盘中英两版该节围栏可解析、版式为北印度、编号不重复、没有空标题。新的完整数据样例覆盖计算图全部编号时,末尾核对段渲染为空。前端相关测试 39 通过、0 失败、0 取消。
- 防复发:`tests/test_report_chart_blank_columns.py::test_full_data_chart_section_draws_each_ready_chart_once`;`frontend/tests/report-calculation-charts.test.tsx` 用真实引擎输出的新样例锁住末尾消失,旧样例仍锁住旧正文保留该节。
- 相关记录:BUG-1203、BUG-1312。
- 复发自:BUG-1203(完整数据版切换后正文无围栏;当时只用读者版样例)。
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
## BUG-1314 | 报告「本命盘行星原始位置」没有上升行
- 状态:fixed-pending-verify(真机清单未走,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #53。星盘页上升行已由 BUG-1201 补上星宿、宿主、pada;报告这张表没有。
- 现象:报告本命位置表从九曜开始,看不到上升的星座、度数、星宿和 pada。
- 根因:表只遍历九曜。上升点上的星宿字段已经算好,报告没用。
- 修复:中文表首行写「上升」,英文写 Ascendant。宫位为 1,星宿用「中文(梵文)」,pada 与星宿计算一致。主星表上升行的尊贵状态写「不适用」(英文 not applicable),星宿主用星宿主星。不给上升另造庙旺。
- 偏离:任务书要求逆行和速度两格写横杠。完整数据版原有的空格清理会把单独的横杠格改成「未列」(英文 not listed),和别的空格子一样。主星表里相邻的两个空格,后一格仍会留下一条横杠。尊贵状态保持「不适用」。测试不要求必须看见那条横杠。
- 验证:虚构日盘上升行的星宿、pada、宫位 1、星宿主与星宿计算一致;尊贵状态为「不适用」。同文件既有用例仍通过(16 通过)。
- 防复发:`test_birth_position_table_starts_with_the_ascendant`。
- 相关记录:BUG-1201、BUG-1099。
- 复发自:无。
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
## BUG-1315 | 星盘页 D9「同位」列几乎全是横杠,读起来像没算
- 状态:fixed-pending-verify(真机清单第 7 步未走,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #53。庙旺列已由 BUG-1202 修好;这一列不是。
- 现象:D9 表「同位」列里,不是同座的行星显示「—」。大多数行星都不是同座,整列几乎都是横杠。
- 根因:横杠表示「不是同位」,和「这一格没数据」长得一样。
- 决策:按任务书 D2 的推荐执行。同座写「是」,不同座写「否」,表头仍是「同位」。不改 BUG-1099:不是 D9 就不显示这一列;旧缓存没有能力声明时整列隐藏。
- 修复:行星行和上升行都改成「是 / 否」。`frontend/DESIGN.md` 同步。缺星宿、庙旺、宫位时别处仍用横杠,本轮没改。
- 验证:`frontend/tests/chart-varga-table.test.tsx` 断言三栏——原值:同位格「同位」、非同位「—」;新值:「是」「否」;原因:BUG-1315。另加「非同位行显示否」和「非 D9 没有同位列」。`chart-page-hook` 里表头「同位」的检查仍在。前端这三份测试 39 通过、0 失败、0 取消。`tsc --noEmit` 退出码 0。
- 防复发:同上前端测试。
- 相关记录:BUG-1099、BUG-1202。
- 复发自:无。
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
## BUG-1317 | 明说要做生时校正时,仍先排完盘再写解读,校正没打开
- 状态:fixed-pending-verify(真机清单未走,没有模型凭据,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #54。staging 首页普通对话第一句说要生时纠正,回答先显示排盘完成,再写校正开不了、走另一个入口。
- 现象:没有打开校正。正文是模型自己写的解读,不是那句固定过渡。
- 触发条件:新对话第一句明确要做校正。普通问题、以及只是觉得解读不准,不在这条里。
- 根因:第 0 步只给排盘工具(BUG-923)。模型必须先排完盘,才看得到校正工具。规则里「明确要校正就打开」夹在禁止句中间,「纠正」又和「用户纠正解读」撞了。BUG-1306 的测试直接调用工具,没有让真实模型选工具;真机清单因没有模型钥匙没跑过。
- 决策:产品选 B。第 0 步在校正工具确实挂上时,可以排盘,也可以打开校正。`toolChoice` 仍是 auto。两个都没按就写答案时,仍走原来的「运行合同不完整」重试,逼它先排盘。不加关键词判断。
- 修复:本命和申报时段的第 0 步,在挂了校正工具时多放这一个工具。还没看盘就顺口提议,不算已经提议过,先去排盘。明确要求则第一步就打开,不先写解读。规则第一句改成「校正就在这个对话里能开」,并把「纠正规则」改称「改读规则」。`FOLLOW_UP_CORRECTION_RULE` 本身没改。没挂校正工具时,第 0 步仍只有排盘工具。
- 验证:见 `docs/tasks/PROGRESS-rectification-step0-entry-20261013.md`。规则原 374 字,新 416 字,增加 42,不超过 80。
- 防复发:第 0 步断言锁住「没挂只有排盘、挂了是两个工具」。另有三条:第一步明确要求不排盘且正文是固定过渡;一步都不按会重试到排盘后的回答;没看盘的顺口提议不占额度。
- 相关记录:BUG-1306、BUG-923、BUG-937、BUG-922。
- 复发自:BUG-1306。
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
@@ -0,0 +1,32 @@
# 进度 · 明说要校正时第一步就能打开(2026-10-13)
- 分支:`codex/rectification-step0-entry-20261013`
- 任务书:`docs/tasks/TASK-rectification-step0-entry-20261013.md`
- 基线:`origin/staging` `0c04755560f0b6a772116130392b56eb2b40ae0e`(任务书写的 `963276ca2` 的后代,也是计划里的 base)
- issue:#54,BUG-1317,复发自 BUG-1306
## 做了什么
1. 本命和申报时段的第 0 步,在校正工具挂上时可以排盘,也可以打开校正。没挂时仍只有排盘。选择仍是自动,没有改成必须调用某个工具。
2. 还没看盘就顺口提议,不算已经提议过,先去排盘。这一轮明确要校正,第一步就打开,正文换成那句过渡。
3. 规则第一句改成校正就在这个对话里能开,并写明校正、纠正、核对出生时间都算。不认同解读改称「改读规则」,避免和「生时纠正」撞词。
4. 规则字数:原 374,新 416,增加 42,上限是加 80。
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 五个相关测试文件 | 149 通过,0 失败,0 取消。类型收窄改完后,入口测试文件再跑 19 通过,0 失败,0 取消 |
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 通过,退出码 0。第一次在测试里把「还没提议」收成永远是空,已改成先记在局部再断言 |
| 前端 lint | `npm run lint --prefix frontend` | 退出码 0,0 error,125 条原有 warning,没有顺手改 |
| 隐私标记 | `.venv\Scripts\python -m pytest tests/test_repo_privacy_markers.py -q` | 通过,退出码 0 |
| 名人生平回测 | 未跑 | 本机没有模型钥匙 |
| 数据库 | 未跑 | 不改表 |
## 偏离
- 没有。真机清单补在原来的 `docs/testing/rectification-explicit-entry-20261012.md`(第 1b 步和第 9 步),没有另写一份。
## 环境缺口
- 真实模型会不会在第一步打开校正,以及名人生平回测,都没有模型凭据,不能写成通过。部署后按真机清单,用 deepseek-v4-flash 和默认模型各走一遍。
@@ -0,0 +1,50 @@
# PROGRESS · 报告翻译缺口 · 2026-10-13
任务书:`docs/tasks/TASK-report-translation-gaps-20261013.md`。Issue #53。执行分支 `codex/report-translation-gaps-20261013`,基于当时的 `origin/staging` `05f880053`(任务书基线 `941b06c19` 的后代)。D1、D2 按任务书推荐执行:图盘并入正文,同位列写「是 / 否」。不需要另请拍板。
## 状态一览
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| T1 正文图盘 | 完成 | 有星座可画的盘各一张北印度图,编号不重复;去掉重复的九分盘和画不出的空宫位标题 |
| T2 中文残留 | 完成 | 虚构日盘、虚构夜盘、公开名人盘(爱因斯坦)粘连 0、功能词 0。基线是粘连 14、功能词 218。未让步 |
| T3 上升行 | 完成 | 位置表首行是上升;主星表尊贵状态「不适用」 |
| T4 同位列 | 完成 | D9 写「是 / 否」,表头仍是「同位」;非 D9 无此列 |
| T5 记录 | 完成 | BUG-1312~1315、本文件、CHANGELOG。真机清单在基线上已有,步骤与实现一致,未改写 |
## 偏离
- 上升行的逆行、速度,任务书写横杠。完整数据版原有清理会把单独的横杠格改成「未列」(英文 not listed),与其它空格相同。主星表相邻两个空格里,后一格仍留下一条横杠。尊贵状态仍是「不适用」。测试不把那条横杠当成必须出现。
- `next build` 与首屏 gzip 未跑。任务书 §8 与执行提示把全量前端测试和 Python 快速门留给验收侧;本轮自测不含这两项,不写成通过。
- `test:db` 未跑:没有改数据库。
- 真机清单 `docs/testing/report-translation-gaps-20261013.md` 已在分支基线上,八步与当前行为一致,本轮没有改它,也没有往 `BLOCKED.md` 加浏览器项。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | 在 `frontend` 下 `npm run lint` | 退出码 0;0 error,125 条既有 warning |
| 改到的前端测试 | `node node_modules/tsx/dist/cli.mjs --test tests/chart-varga-table.test.tsx tests/chart-page-hook.test.ts tests/report-calculation-charts.test.tsx` | 39 通过,0 失败,0 取消 |
| 图盘 / 上升 / 残留 | `.venv\Scripts\python -m pytest tests/test_report_chart_blank_columns.py -q --tb=line` | 16 通过,退出码 0 |
| 英文版、附录、读者版、星盘表合同、完整数据修复、图盘围栏 | `.venv\Scripts\python -m pytest tests/test_report_english_edition.py tests/test_reader_appendix_language.py tests/test_reader_appendix_shared_rules.py tests/test_report_reader_main.py tests/test_chart_page_vedic_tables_contract.py tests/test_report_full_data_fix.py tests/test_report_chart_block.py -q --tb=line` | 88 通过,退出码 0 |
| 隐私标记 | `.venv\Scripts\python -m pytest tests/test_repo_privacy_markers.py -q --tb=line` | 63 通过,退出码 0 |
未跑:前端全量 `npm test`、Python 快速门、`npm run test:db`、`next build`。
## 第 1 次打回(2026-10-13)
修复单:`docs/tasks/TASK-report-translation-gaps-fix-20261013.md`。报告四项本身不改。
- 修复单要求变基到当时的最新 `origin/staging`(`d79f42b04ba225e5be39d8567a7fdd38223e19fe`)。本地变基后的文件内容与现在相同,但这台机器不允许改写已经推送过的分支。因此改为把这份 staging 合并进本分支:测试版是本分支的祖先,推送不用改写历史。报告生成和星位中文原名没有再动。
- 只改 `tests/test_chara_karaka_8_bphs_order.py` 里报告星位表那一条。期望值改成 `translate_zh_glued_phrases(KARAKA_CN[role])`,也就是「灵魂星(AK)」这一类。`scripts/jaimini.py` 的 `KARAKA_CN` 没改。
- 检索 `tests` 与 `frontend/tests`:按 `KARAKA_CN` 原字面值断言完整数据版报告正文的,只有这一条。`frontend/tests/fixtures/report-reader-main-fictional-zh-pair.json` 未改。咨询金样和旧阅读版夹具里的「灵魂星AK」不是这份报告正文,未改。
- `tests/test_prashna_sphuta_review_result_validator.py` 里基线已有的失败未动。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 星位表、§8 六个文件、隐私标记 | `.venv\Scripts\python -m pytest tests/test_chara_karaka_8_bphs_order.py tests/test_report_chart_blank_columns.py tests/test_report_english_edition.py tests/test_reader_appendix_language.py tests/test_reader_appendix_shared_rules.py tests/test_report_reader_main.py tests/test_chart_page_vedic_tables_contract.py tests/test_repo_privacy_markers.py -q --tb=line` | 154 通过,退出码 0 |
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 前端 lint | `npm run lint --prefix frontend` | 0 error,125 warning |
未跑:前端全量 `npm test`、Python 快速门、`npm run test:db`(本轮没动数据库)、`next build`。
+2
View File
@@ -211,6 +211,8 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
| `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 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
@@ -0,0 +1,97 @@
# TASK · 明说要校正时第一步就能打开(issue #54,BUG-1306 复发)
- 日期:2026-10-13
- 来源:Gitea issue #54(agent 循环试点),产品负责人在 staging 首页普通对话(deepseek-v4-flash)第一句说「请你帮我生时纠正」,回答「已完成 3 步、本轮技法 43 项」后写「生时校正这次我这边开不了,它走的是另一个入口……」,没有打开校正
- 执行方:**Grok(Windows + Docker)**,分支 `codex/rectification-step0-entry-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**BUG-1317**(BUG-1316 已预留给 issue #55;开工时核对 `docs/BUG_HISTORY.md` 最大号)。BUG-1317 标「复发自 BUG-1306」,并在 BUG-1306 记录里补一行指向它
## 1. 基线
- 基线 commit:`origin/staging` `963276ca2`
- `git log 586561eb..origin/staging -- frontend/src/mastra frontend/src/lib/consultation-thinking-plan.ts frontend/src/lib/rectification-in-chat*.ts frontend/src/lib/stream-agent-response.ts frontend/src/app/api/consult frontend/src/hooks/use-consultation-run.ts` 为空:BUG-1306 部署后这条链没人动过,**不是代码回归**。
## 2. 事故实证
- 产品负责人这一步就是 `docs/testing/rectification-explicit-entry-20261012.md` 第 1 步(「请你帮我进行生时纠正」),未通过。该清单(BUG-1306 的 T5)因无模型凭据从未跑过(`BLOCKED.md`「TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型」)。
- 用户看到的是模型自由写的整段解读。而工具只要收到 `explicitRequest=true`,不论放行还是拒绝,服务端都会用固定句覆盖模型正文(`frontend/src/lib/rectification-in-chat.ts` 的 `RECTIFICATION_DIRECT_*_COPY`,`frontend/src/lib/stream-agent-response.ts::rectificationDirectRunCompleted`)。所以**模型没有带 `explicitRequest=true` 调用 `offer-birth-time-rectification`**,这是代码可证的,不是推测。
- 「另一个入口」「开不了」在提示词、skill、`frontend/src` 里都搜不到,是模型自己编的;「我也不会先用一两件事去反推一个更窄的时间」是在复述规则里的禁止句。
BUG-1306 的防复发措施逐条核对仍在:唯一规则 `RECTIFICATION_OFFER_RULE`(`frontend/src/lib/consultation-thinking-plan.ts`)注入首轮、追问轮与时段路线;`createRectificationOfferTool` 与 `finishExplicitRectificationRequest`(`frontend/src/mastra/consultation-tools.ts`);`decideRectificationOfferWith`(`rectification-in-chat.ts`);`contractReady()` 对 `rectificationDirectEntry` 放行(`stream-agent-response.ts`);浏览器自动打开(`frontend/src/hooks/use-consultation-run.ts`)。
## 3. 根因
1. **第 0 步只给排盘工具。** `consultationNatalPrepareStep` / `consultationWindowPrepareStep`(`consultation-tools.ts`)在 `stepNumber === 0` 时 `activeTools` 只有排盘工具(BUG-923 定、BUG-937 改 `auto`)。模型必须先排完整盘(43 项),此时已进入解盘状态,才轮到它考虑校正工具。
2. **规则正向句埋在禁止句中间。** `RECTIFICATION_OFFER_RULE` 以「用户说你推得不准时」开头,「明确要做校正就调用…explicitRequest=true」夹在「不承诺能收窄」「不在对话里收集经历」之间;模型照禁止句执行。
3. **「纠正」撞词。** 同一规则写「按纠正规则认下、改读」,`FOLLOW_UP_CORRECTION_RULE` 讲的是用户「纠正」解读;用户说「生时纠正」被归到「读法问题、不提校正」那一支。
4. **没告诉模型校正就在本对话里可开。**
5. **为什么没拦住:** `frontend/tests/rectification-explicit-entry-20261012.test.ts` 直接 `tool.execute({explicitRequest:true})` 并用正则查规则文本;「真实模型是否选择调用」只能靠 T5,而 T5 一直是环境缺口。
## 4. 决策记录
- 产品负责人 2026-10-13 在 issue #54 选 **B:改规则 + 第 0 步可调用校正工具**(备选 A 只改规则、C 每个新对话加一次小模型意图判断均未选)。
- **推翻 BUG-923「第 0 步只放排盘工具」**:第 0 步 `activeTools` 为「排盘工具 + `offer-birth-time-rectification`」(仅当本轮 `ctx.rectificationOffer` 存在,即该工具确实挂上时)。`toolChoice` 仍为 `"auto"`(BUG-282 / BUG-937 不变)。
- 保险沿用现有机制,不另造:第 0 步模型既没排盘、也没走直接进入时,`contractReady()` 为假,`stream-agent-response.ts` 已有的 `runtime-contract-retry` 会重试,仍不就绪则按现有降级 / 失败处理。BUG-922 / 923 的「不看盘就写答案」不得因此复发。
- 不加关键词 / 正则分类器(BUG-976/977、BUG-1247 的既有决定不变)。
- 产品负责人已确认:普通问题(「看看事业」)仍先排盘再解读;「你说得不准」仍是认下改读、不主动拉去校正。
## 5. 硬红线
1. 不写任何按关键词判断意图的代码(含正则、词表匹配进入校正)。规则里列举「校正 / 纠正 / 核对出生时间」等说法只作为给模型的说明。
2. `toolChoice` 不得改成 `required` 或指名工具(BUG-282 / BUG-937)。
3. 第 0 步不得放出排盘与校正之外的工具;第 1 步起不变。
4. 不改 `decideRectificationOfferWith` 的资格判断(本人 / 产品开关 / 进行中的校正 / 价格)、不改固定句文案、不改计费。
5. 规则只在 `RECTIFICATION_OFFER_RULE` 一处定义(BUG-1248 / 1306),工具描述不复述规则。
6. 不改数据库结构;不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/**`。
7. 改既有断言按 §7.3 写「原值 / 新值 / 原因」三栏。开工先 grep:`grep -rn "consultationNatalPrepareStep\|consultationWindowPrepareStep\|RECTIFICATION_OFFER_RULE\|纠正规则\|activeTools" frontend/tests tests`(已知:`frontend/tests/consultation-agentic-runtime.test.ts` 「natal first step exposes only the chart calculation tool」与 window 对应用例、`frontend/tests/consult-single-pass-answer-20260927.test.ts`、`frontend/tests/rectification-explicit-entry-20261012.test.ts`)。
8. 改了普通对话提示词:按 AGENTS.md §10 需要名人生平回测,本轮无模型凭据,写成环境缺口(见 §10),不得写成通过。
## 6. 任务分解
### T1 · 第 0 步放出校正工具
- `consultationNatalPrepareStep` / `consultationWindowPrepareStep` 改为能知道本轮是否挂了校正工具(例如改成工厂 `consultationNatalPrepareStepFor({ rectificationOffer: boolean })`,或在 `route.ts` 组装时传入),第 0 步 `activeTools` = 排盘工具 +(有则)`RECTIFICATION_OFFER_TOOL_ID`;没挂校正工具时与现在逐字节相同。
- 第 0 步模型以**非** `explicitRequest` 方式调用校正工具(还没看过盘就想「顺口提议」):工具返回一句「先排盘,看过盘再决定要不要提」之类的说明,**不**记为已提议(不占用「同一次对话提议过就不再提」的额度),之后照常排盘。
- 验收:
- `consultation-agentic-runtime.test.ts` 两条第 0 步断言按三栏改,新增「没挂校正工具时第 0 步只有排盘工具」「挂了时第 0 步是两者」。
- 新增运行时测试(用现有假模型 / 假流的写法):① 第 0 步直接 `explicitRequest=true` → 不调用排盘、正文是固定过渡句、`contractReady` 为真;② 第 0 步什么工具都不调用就写字 → 走 `runtime-contract-retry`,最终答案来自排盘之后;③ 第 0 步非 explicit 调用校正工具 → 不记为已提议,随后排盘、正常作答。
### T2 · 改写规则
- `RECTIFICATION_OFFER_RULE` 重排,**正向句放第一句**,大意:「校正就在这个对话里能开。用户这一轮明确要做出生时间校正(说法可能是校正、纠正、核对出生时间、看看出生时间准不准等),第一步就调用 offer-birth-time-rectification 并传 explicitRequest=true,不用先排盘,工具通过后只写那一句过渡。」其后才是「推得不准 → 先分读法 / 时间」与各禁止句。
- 把规则里描述「用户不认同解释」那支的「纠正规则」改称「改读规则」(`FOLLOW_UP_CORRECTION_RULE` 的名字与内容不改,只改 `RECTIFICATION_OFFER_RULE` 里引用它的叫法),避免与「生时纠正」撞词。
- 提示词总长不得明显增长(BUG-1256 瘦身后的口径):新规则字数 ≤ 原规则 + 80 字,进度记录写原 / 新字数。
- 验收:`rectification-explicit-entry-20261012.test.ts` 里查规则文本的断言按三栏改;新增断言:规则第一句含 `explicitRequest=true`,规则文本不含「纠正规则」。
### T3 · 记录
- `docs/BUG_HISTORY.md` 新增 BUG-1317(复发自 BUG-1306:为何旧测试与 T5 没拦住、本次推翻 BUG-923 第 0 步限制的决策、保险机制),BUG-1306 记录补「复发:BUG-1317」。
- `docs/tasks/PROGRESS-rectification-step0-entry-20261013.md`、`CHANGELOG.md`。
- 真机清单:在 `docs/testing/rectification-explicit-entry-20261012.md` 第 1 步后补一行「新对话第一句:『请你帮我生时纠正』(不带『进行』)」,并加第 9 步「新对话问『看看我今年事业』:照常先排盘解读,不出现校正」;或另写 `docs/testing/rectification-step0-entry-20261013.md` 引用原清单。
## 7. 让步顺序
T1 与 T2 都必须做(只做 T2 就是产品没选的 A)。时间不够时 T1 的第③条(非 explicit 调用不占额度)可降为「记录在 BLOCKED.md,回报里说明」。
## 8. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | 前端 `tsc --noEmit`、`npm run lint`;`node --test` 跑 `consultation-agentic-runtime.test.ts`、`consult-single-pass-answer-20260927.test.ts`、`rectification-explicit-entry-20261012.test.ts` 及新增文件(fail 与 cancelled 都为 0)。前端全量 `npm test`、整站构建不跑,以 Claude 为准 |
| Claude 验收 | 上述 + 前端全量 `npm test` 与基线逐条比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 逐行审规则文本 |
| 不需要 | `test:db`、Python 快速门(不动 Python、不动数据库访问层) |
## 9. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-step0-entry-20261013 .worktrees/rectification-step0-entry-20261013 origin/staging
cd .worktrees/rectification-step0-entry-20261013
git log -1 --format=%h # 应为 963276ca2 或其后代
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号;BUG-1316 已预留给 #55
grep -rn "consultationNatalPrepareStep\|consultationWindowPrepareStep\|RECTIFICATION_OFFER_RULE\|纠正规则\|activeTools" frontend/tests tests
```
## 10. 环境缺口(预告)
- **真实模型是否会在第一步调用校正工具**,本机与 Grok 侧都没有模型凭据(供应商 key 加密存于管理端数据库),无法自动验证;名人生平回测同理。部署后由产品负责人按真机清单用 deepseek-v4-flash 与默认模型各走一遍(每个场景至少两次)。在此之前 BUG-1317 只能是 `fixed-pending-verify`。
@@ -0,0 +1,144 @@
# TASK · 个人报告开篇「重点」页做成摘要卡(issue #55)
- 日期:2026-10-13
- 来源:Gitea issue #55(agent 循环试点),产品负责人截图:报告第 1/39 章「重点」只有一行粗体标题、一行「重点:当前主运 MD …」、一个「## 重点」大标题和四句数据句,「一点 UI 设计都没有」
- 执行方:**Grok(Windows + Docker)**,分支 `codex/report-focus-card-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**BUG-1316**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号为 BUG-1315)
## 1. 基线
- 基线 commit:`origin/staging` `856bc850b`
- 个人报告走完整数据版:`frontend/src/lib/personal-report-longform-generate.ts` 请求体 `edition: "full_data"` → `scripts/pl9_full_data_export.py::render_full_data_markdown` → `scripts/pl9_reader_export.py::render_pl9_parity_markdown`。
- 复现(虚构盘):
```bash
PYTHONPATH=.:scripts python - <<'EOF'
import copy
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from tests.test_report_english_edition import CASES, _packet
print(_pl9_export_markdown_for_edition(copy.deepcopy(_packet(CASES["day"])), "full_data")[:1200])
EOF
```
## 2. 事故实证(`856bc850b`)
虚构日盘中文完整数据版开头,与产品截图结构一致:
```
# 印度占星星盘报告
重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。
## 重点
- 事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。
- 财富第2宫主 … / 婚恋第7宫主 …
- 这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。
## 出生资料与图盘 …
```
| # | 问题 | 位置 |
| --- | --- | --- |
| 1 | 「重点」三次:目录第 1 章、「重点:…」一行、`## 重点` | `scripts/pl9_interpretation_packs.py::opening_focus_block` 写死该行;`scripts/pl9_full_data_export.py::_sanitize_pl9_ai_density_markdown` 在首行没有「摘要:/结论:/重点:」时会注入一段固定的「摘要… / ## 重点 1.2.3.」清单,`_insert_opening_focus` 也按这一行去重,所以这一行是为了躲开注入才存在的 |
| 2 | 报告标题与正文一样大 | `frontend/src/components/personal-report/personal-report-markdown-view.tsx::markdownComponents` 无 `h1` 映射;`frontend/src/app/globals.css` 中 `.personal-report-md-article h1` 只设字体(注释仍写「像 `.personal-report-cover h1`」,封面已于 09-29 去掉) |
| 3 | 行话与车轱辘话:MD / AD / PD 无起止日期;「功能吉凶为功能吉星」;「宫主 火星 落在」多空格 | `opening_focus_block::lord_line`,`_role` 已返回「功能吉星」又被拼在「功能吉凶为」后 |
| 4 | 无版式:四句纯文本列表 | 同上;阅读器把首个 `##` 之前的内容当 lead,与 `## 重点` 同在第 0 章卡片里 |
## 3. 根因
10-08 `TASK-report-full-data-fix-20261008` T3 按上游加了「开篇重点:当前三级大运 + 三宫宫主」,验收只要求内容存在,没有任何呈现规定;`frontend/DESIGN.md`「Personal report reader」也没有开篇或标题的条目。09-29 去掉报告封面后,标题只剩字体,没人回头补。**不是回归,是从未设计。** `docs/BUG_HISTORY.md` 检索「重点:」「opening_focus」「当前主运 MD」无记录。
## 4. 决策记录
- 产品负责人 2026-10-13 在 issue #55 选 **A:做成设计过的摘要卡**(备选 B 改写两三句、C 删页均未选)。
- 标题下**写名字和生成日期**;**保留**末尾提醒句「这段时间可能突出这些主题,不是确定会发生的事」。
- 问题 1~4 一并修。
- 不推翻任何既有红线;不恢复 09-29 去掉的报告封面(只给标题本身样式)。
## 5. 硬红线
1. 不改引擎数值(大运、宫主、落宫、尊贵、功能吉凶的判定);只改呈现。
2. 摘要卡的数据由 Python 生成结构化围栏(与 `jyotish-chart` 同一模式),前端只解析和渲染;前端不自算大运或宫主。
3. 只做一个新组件;不写第二套 Markdown 渲染、不加 spinner / 骨架(AGENTS.md §6)。
4. 旧报告(正文里没有新围栏)必须照旧显示,不崩、不空白。
5. 名字只在报告阅读页(本人登录可见)显示;**不得**把名字加进 `frontend/src/lib/report-public-projection.ts` 的公开投影,也不写进任何测试 / 文档 / 提交里的真实姓名(测试用虚构名)。
6. 不改数据库结构与迁移;不碰 `frontend/src/app/(app)/page.tsx`;`scripts/jyotish_api_server.py` 不增长。
7. 改既有断言按 §7.3 写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "重点:\|## 重点\|Focus: current\|opening_focus\|personal-report-md-lead\|personal-report-md-article h1" tests frontend/tests`(已知:`tests/test_report_full_data_fix.py` 173–174、197 行附近)。
8. 不升级依赖,不顺手修别的 warning。
## 6. 任务分解
### T1 · 引擎输出结构化「重点」围栏(Python)
- `opening_focus_block` 改为输出:
````
## 重点
```jyotish-focus
{"version":1,"periods":[{"level":"md","lord":"Rahu","start":"YYYY-MM-DD","end":"YYYY-MM-DD"},…ad,pd],
"domains":[{"key":"career","house":10,"lord":"Saturn","placed":9,"dignity":"…","role":"…"},…wealth/2,relationship/7],
"caveat":"这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"}
```
````
起止日期取当前 MD / AD / PD 行自带的 `start` / `end`(`_timing(packet)["dasha"]`,见同文件英文分支已在用 `current_ad.get('start')`);拿不到的写 `null`,不编。`dignity` / `role` 用现有 `_dignity` / `_role` 的公开词(中文版中文、英文版英文)。
- 删掉「重点:当前主运 MD …」那一行(中英两版)。
- `_sanitize_pl9_ai_density_markdown` 的「开篇摘要」判断与 `_insert_opening_focus` 的去重,改为识别 `## 重点`(英文 `## Focus`)及其下的 `jyotish-focus` 围栏,**不得**因删掉那一行而重新注入固定清单。
- 围栏格式写一个 Python 构造函数(建议放 `scripts/report_chart_block.py` 旁新建 `scripts/report_focus_block.py`,或同文件),附 `FOCUS_FENCE_RE`。
- 中文残留门(BUG-1312 的 `_zh_body_residue`)对围栏的处理与 `jyotish-chart` 一致:结构化负载不计入正文。
- 验收:
- 新测试:虚构日盘中英两版,`## 重点` 下恰有一个可解析的 `jyotish-focus` 围栏;`periods` 三级 lord 与 `_current_lords` 一致、日期与大运表一致;`domains` 三行宫号 10 / 2 / 7;全文不再出现「重点:当前主运」与 `MD ` / `AD ` / `PD `(中文版);不出现固定清单「先核对出生资料、图盘」。
- `tests/test_report_full_data_fix.py` 相关断言按三栏改。
- `tests/test_report_chart_blank_columns.py`、`tests/test_report_english_edition.py`、`tests/test_reader_appendix_language.py`、`tests/test_report_reader_main.py` 全绿。
### T2 · 阅读页摘要卡组件(前端)
- `frontend/src/lib/` 新增解析器(参照 `report-chart-block.ts::parseReportChartBlock`),`personal-report-markdown-view.tsx` 的 `pre` 分支识别 `jyotish-focus` 渲染新组件 `ReportFocusCard`(放 `frontend/src/components/personal-report/`)。
- 版式(写进 DESIGN.md,用现有 token,不新增颜色):
- 第一行「现在走的大运」:主运 · 子运 · 次子运,各写行星中文名 + 起止(`YYYY.MM – YYYY.MM`),三者横排,窄屏换行;中文不出现 MD / AD / PD。
- 下面三行:事业 / 财富 / 婚恋。每行左侧领域名,右侧「第 N 宫主 · 行星 · 落第 M 宫」+ 两个小标签(尊贵、功能吉凶),标签沿用站内已有的 pill / badge 样式。
- 最后一行提醒句,弱化字色、小一号。
- 手机宽度不横向滚动;深浅色都可读。
- 解析失败:显示一句「重点数据无效」(英文 "Focus data is invalid"),与图盘无效同口径,不崩。
- 导出与公开投影:`frontend/src/lib/report-export-blocks.ts` 把该围栏转成可读文字(参照 `chartBlockText`),导出文件里不得出现原始 JSON;`report-public-projection.ts` 对该围栏的处理与 `jyotish-chart` 一致(按其现有可见性规则),不新增名字字段。
- 验收:新测试——用 T1 真实引擎输出生成的 golden(`frontend/tests/fixtures/`,`fixtureProvenance` 写明命令,虚构盘),渲染出三级大运与三行领域、无 `MD`、有提醒句;坏 JSON 渲染失败句;导出文本无 `{"version"`;旧正文(无围栏)快照不变。
### T3 · 标题样式 + 名字与日期(前端)
- `markdownComponents` 增加 `h1` 映射与样式:显示字体、明显大于 h2、上下留白;去掉 globals.css 里指向已删封面的过时注释。
- 标题下一行小字:`{名字} · 生成于 YYYY-MM-DD`。名字取报告文档 `subject.displayName`(`personal-report-generation.ts` 已写入),日期取 `createdAt`;若阅读页当前拿不到 `displayName`,从报告读取接口带出(仅本人可见的接口,不改表结构)。没有名字时只写日期;标题本身已含名字(引擎 `_pl9_public_report_title` 在有名字时会写「X的印度占星星盘报告」)时不重复名字。
- 英文版:`{name} · Generated YYYY-MM-DD`。
- 验收:前端测试覆盖「有名字」「无名字」「标题已含名字」三种;`tsc --noEmit` 0 错、`npm run lint` 0 error。
### T4 · 记录
- `frontend/DESIGN.md`「Personal report reader」加「开篇重点卡」与「报告标题」两条(版式、token、旧报告兼容、导出口径)。
- `docs/BUG_HISTORY.md` 新增 **BUG-1316**(报告开篇「重点」页无设计:三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌)。
- `docs/tasks/PROGRESS-report-focus-card-20261013.md`、`CHANGELOG.md`。
- 真机清单 `docs/testing/report-focus-card-20261013.md`(已随本任务书提供,可补充)。
## 7. 让步顺序
T1 → T2 → T3 → T4。T3 的「名字」若需要改报告读取接口且发现牵涉数据库或公开投影,先只做日期 + 标题样式,名字写进 `BLOCKED.md` 并在回报里说明,由 Claude 判断是否 @产品负责人。
## 8. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | `python -m pytest tests/test_report_full_data_fix.py tests/test_report_chart_blank_columns.py tests/test_report_english_edition.py tests/test_reader_appendix_language.py tests/test_report_reader_main.py -q`;前端 `tsc --noEmit`、`npm run lint`、改到 / 新增的前端测试单文件(看 fail 与 cancelled 都为 0)。前端全量 `npm test`、Python 快速门、整站构建**不跑**,以 Claude 为准 |
| Claude 验收 | 上述全部 + 全量 `pytest` 与基线逐条比对 + 前端全量 `npm test` + `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 重生成虚构盘中英报告核对围栏 |
| 不需要 | `test:db`(不动数据库访问层;若 T3 改了报告读取接口的查询,则需要并按 `windows-known-failures.txt` 比对) |
## 9. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/report-focus-card-20261013 .worktrees/report-focus-card-20261013 origin/staging
cd .worktrees/report-focus-card-20261013
git log -1 --format=%h # 应为 856bc850b 或其后代
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号
grep -rn "重点:\|## 重点\|Focus: current\|opening_focus\|personal-report-md-lead\|personal-report-md-article h1" tests frontend/tests
```
## 10. 环境缺口(预告)
- 只对**新生成**的报告生效;旧报告照旧显示。
- 浏览器级观感留给 `docs/testing/report-focus-card-20261013.md`。
@@ -5,6 +5,7 @@
| 步 | 怎么做 | 通过标准 |
| --- | --- | --- |
| 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 |
| 1b | 新对话第一句:「请你帮我生时纠正」(不带「进行」) | 同样直接打开校正,不先出「已完成几步」再写解读。BUG-1317 |
| 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 |
| 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 |
| 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 |
@@ -12,5 +13,6 @@
| 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 |
| 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 |
| 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 |
| 9 | 新对话问「看看我今年事业」 | 照常先排盘再解读,不出现校正,下面没有提议卡 |
做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。
@@ -0,0 +1,13 @@
# 真机清单 · 报告开篇「重点」摘要卡(issue #55,BUG-1316)
部署到 staging 后,**重新生成一份个人报告**(旧报告不会变),手机和电脑各看一遍。
1. 第 1 章最上面是报告标题,明显比正文大;标题下一行小字写「名字 · 生成于 年-月-日」(没有名字的盘只写日期;标题里已有名字时不重复)。
2. 「重点」只出现一次(目录里那一条除外),没有单独一行「重点:当前主运 MD …」。
3. 第一行写「现在走的大运」:主运、子运、次子运各是哪颗星、从哪年哪月到哪年哪月;没有 MD / AD / PD 字样。
4. 下面三行:事业、财富、婚恋,各写「第几宫主、哪颗星、落第几宫」,后面跟两个小标签(强弱、吉凶);没有「功能吉凶为功能吉星」这种重复。
5. 最后一行小字提醒「这段时间可能突出这些主题,不是确定会发生的事」。
6. 手机上不需要左右滑;深色、浅色模式都看得清。
7. 切到 English:同样的卡片,英文标签。
8. 导出一份文件,打开后「重点」是文字,不是一串花括号代码。
9. 打开一份**以前**生成的旧报告:照旧能看,不白屏。
+1 -1
View File
@@ -1184,7 +1184,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| 盘型 | 列 | 说明 |
| --- | --- | --- |
| 本命 | 上面八列 | 不变(与基线渲染逐字节相同) |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示此列,其他分盘不显示。2026-10-13:同座写「是」,不同座写「否」,表头仍是「同位」(不再用横杠,避免看成没算)。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) |
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
+2 -2
View File
@@ -25,8 +25,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
+8 -2
View File
@@ -1215,11 +1215,17 @@ export async function POST(request: Request) {
};
const natalStreamOptions = {
...streamOptions,
prepareStep: consultationNatalPrepareStep,
prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({
stepNumber: input.stepNumber,
rectificationOffer: true,
}),
};
const windowStreamOptions = {
...streamOptions,
prepareStep: consultationWindowPrepareStep,
prepareStep: (input: { stepNumber: number }) => consultationWindowPrepareStep({
stepNumber: input.stepNumber,
rectificationOffer: true,
}),
};
const answerPhaseSignal = runClock.answerSignal;
const startAnswerPhase = () => {
@@ -152,7 +152,7 @@ function VargaRow({ row, supportsDignity, supportsVargottama }: { row: ChartView
<td>{row.house}宫</td>
<td>{row.natalSignLabel}</td>
{supportsDignity && <td className="chart-page-planet-mid">{row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"}</td>}
{supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>}
{supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "是" : "否"}</td>}
</tr>
);
}
@@ -167,7 +167,7 @@ function VargaAscendantRow({ row, supportsDignity, supportsVargottama }: { row:
<td>1宫</td>
<td>{row.natalSignLabel}</td>
{supportsDignity && <td className="chart-page-planet-mid">不适用</td>}
{supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>}
{supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "是" : "否"}</td>}
</tr>
);
}
@@ -459,7 +459,7 @@ export function natalAnswerShapeInstruction(): string {
* Never decided from keywords. Defined here once; the offer tool names the
* same parameters and does not restate this rule.
*/
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户这一轮明确要做校正,就在写回答之前调用 offer-birth-time-rectification 并传 explicitRequest=true;工具通过后只写那一句过渡,不写解读。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
export const RECTIFICATION_OFFER_RULE = "校正就在这个对话里能开:用户这一轮明确要做出生时间校正(说法可能是校正、纠正、核对出生时间、看看出生时间准不准等),第一步就调用 offer-birth-time-rectification 并传 explicitRequest=true,不用先排盘,工具通过后只写那一句过渡,不写解读。用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在看过盘之后调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
+31 -18
View File
@@ -176,34 +176,41 @@ export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Step 0 exposes only the natal chart tool. Some thinking-mode providers
* reject named and required toolChoice (BUG-282, BUG-937); the first step
* therefore narrows with activeTools and leaves the choice as auto. Later
* steps stay auto so the model can write after the request-scoped
* calculation is in hand.
* Step 0 narrows with activeTools and leaves toolChoice auto. Thinking-mode
* providers reject named and required toolChoice (BUG-282, BUG-937).
* When this turn mounted the rectification offer, that tool is available
* beside the chart tool so an explicit request can open before a reading
* (BUG-1317). Without it, step 0 is still only the chart tool (BUG-923).
* Later steps stay auto.
*
* Window and general agents must not share this hook: they do not own this tool.
* Window and general agents must not share the natal hook.
*/
export function consultationNatalPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
export function consultationNatalPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
if (!input.rectificationOffer) {
return {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
return {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
toolChoice: "auto" as const,
};
}
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
export function consultationWindowPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
if (!input.rectificationOffer) {
return {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
return {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
toolChoice: "auto" as const,
};
}
const CONSULTATION_DOMAIN_DURATION_MS = 31_000;
@@ -1174,6 +1181,12 @@ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntime
}
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (input.explicitRequest !== true && !ctx.state.consultationToolCompleted) {
return {
offered: false as const,
note: "先排盘,看过盘再决定要不要提。这次不算已经提议过。",
};
}
if (hook.alreadyOffered && input.userAsked !== true && input.explicitRequest !== true) {
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
+16 -1
View File
@@ -138,7 +138,11 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
assert.doesNotMatch(header, /星宿|宿主|pada/);
// Same wording as the D1 status column (BUG-1202), friendship levels included.
assert.match(row(markup, "土星"), />入庙\(Own Sign\)<[\s\S]*>同位</);
// 原值:同位行星单元格是「同位」,非同位是「—」
// 新值:同位写「是」,非同位写「否」
// 原因:BUG-1315。横杠和「没数据」长得一样,表头仍叫「同位」
assert.match(row(markup, "土星"), />入庙\(Own Sign\)<[\s\S]*>是</);
assert.match(row(markup, "太阳"), />否</);
assert.match(row(markup, "太阳"), />落陷\(Debilitated\)</);
assert.match(row(markup, "月亮"), />入友\(Friendly Sign\)</);
assert.match(row(markup, "火星"), />中性\(Neutral\)</);
@@ -168,6 +172,17 @@ function assertColumns(markup: string, dignity: boolean, vargottama: boolean) {
for (const [, cells] of rows) assert.equal((cells!.match(/<(?:th|td)\b/g) ?? []).length, count);
}
test("a non-vargottama row says 否 and a non-D9 chart has no same-sign column", async () => {
const view = await vargaView();
const d9 = render(view, "D9");
assert.match(row(d9, "太阳"), />否</);
const d10 = view.vedic.vargas.find((item) => item.id === "D10")!;
assert.equal(d10.supportsVargottama, false);
const markup = tableMarkup(d10);
assert.doesNotMatch(markup, />同位</);
assert.doesNotMatch(markup, />否</);
});
test("server capabilities retain dignity for every Dn and vargottama only for D9", async () => {
const view = await vargaView();
for (const varga of view.vedic.vargas) {
@@ -679,26 +679,40 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
test("natal first step exposes only the chart calculation tool", () => {
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有排盘工具;挂了时是排盘工具和 offer-birth-time-rectification。toolChoice 仍是 "auto"。第 1 步不收窄。
// 原因: BUG-937 保持 auto;BUG-1317 按产品选择,第 0 步在工具确实挂上时可以打开校正。
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: false }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
test("window first step requires the window consultation tool", () => {
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有窗口排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有窗口排盘工具;挂了时再加上 offer-birth-time-rectification。toolChoice 仍是 "auto"。
// 原因: 与本命同一条 BUG-1317 决定,申报时段也要能在第一步打开校正。
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-window-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
@@ -233,8 +233,12 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
// 原值:prepareStep: consultationNatalPrepareStep / consultationWindowPrepareStep,函数本身不知道校正工具在不在。
// 新值:路由把 rectificationOffer: true 传进去,因为这两条路线都会挂上校正工具。
// 原因:BUG-1317,第 0 步在工具挂上时要能打开校正;没传时函数仍只留排盘工具。
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationNatalPrepareStep\(\{/);
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationWindowPrepareStep\(\{/);
assert.match(route, /rectificationOffer: true/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
File diff suppressed because one or more lines are too long
@@ -72,8 +72,14 @@ function offerTool(options: {
alreadyOffered?: boolean;
decide?: () => Promise<OfferDecision>;
loadResumableSessionId?: () => Promise<string | null>;
/** False only for a step-0 call that has not seen the chart yet. */
chartReady?: boolean;
} = {}) {
const state = createConsultationRuntimeState();
if (options.chartReady !== false) {
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
}
let decisions = 0;
let loads = 0;
const tools = createConsultationTools({
@@ -520,6 +526,111 @@ test("an ordinary consultation still retries once when the calculation never fin
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("step 0 explicit request opens rectification without calculating a chart", async () => {
const fresh = offerTool({ chartReady: false });
const result = await fresh.tool.execute({ explicitRequest: true });
assert.equal(result.directEntry, "new");
assert.equal(fresh.state.consultationToolCallCount, 0);
assert.equal(fresh.state.consultationToolCompleted, false);
assert.equal(fresh.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-explicit",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘,然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(events.some((event) => event.type === "run.completed" && event.responseKind === "smalltalk"), true);
});
test("step 0 text without a tool still retries into a chart answer", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-empty",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘就想写。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(state.rectificationDirectEntry, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("a soft offer before the chart is not recorded, then a chart answer can still offer", async () => {
const fresh = offerTool({ chartReady: false });
const early = await fresh.tool.execute({});
assert.equal(early.offered, false);
assert.match(String(early.note), /先排盘,看过盘再决定要不要提/);
// 记在局部上再断言。直接断言 state 上的字段会把它收成永远是空,排盘后再读就对不上类型。
const offerBeforeChart = fresh.state.rectificationOffer;
assert.equal(offerBeforeChart, undefined);
assert.equal(fresh.state.rectificationDirectEntry, undefined);
assert.equal(fresh.decisions(), 0);
fresh.state.consultationToolCompleted = true;
fresh.state.consultationToolSuccessCount = 1;
const later = await fresh.tool.execute({});
assert.equal(later.offered, true);
const offerAfterChart = fresh.state.rectificationOffer;
assert.equal(offerAfterChart?.status, "offered");
assert.equal(offerAfterChart?.autoOpen, undefined);
assert.equal(fresh.decisions(), 1);
const response = streamAgentResponse({
runId: "run-step0-soft",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})(),
retry: async () => {
throw new Error("chart contract should already be ready");
},
});
const events = await readStream(response);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(RECTIFICATION_DIRECT_NEW_COPY), false);
});
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
@@ -530,7 +641,12 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:规则以「用户说你推得不准时」开头,并写「按纠正规则认下」。
// 新值:第一句就写 explicitRequest=true,并改称「改读规则」。全文不含「纠正规则」。
// 原因:BUG-1317,「生时纠正」和「纠正解读」撞词,正向句被埋在禁止句中间。
assert.match(RECTIFICATION_OFFER_RULE.split("。")[0] ?? "", /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /按改读规则认下、改读,不提校正/);
assert.doesNotMatch(RECTIFICATION_OFFER_RULE, /纠正规则/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
const tools = src("../src/mastra/consultation-tools.ts");
@@ -117,6 +117,11 @@ test("the server decides whether a card may be shown: self, product on, no case
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
const state = createConsultationRuntimeState();
// 原值:建出来还没排盘也能直接提议。
// 新值:这些用例表示已经看过盘,所以先标计算完成。
// 原因:BUG-1317,还没排盘的顺口提议不算已经提议过。
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
@@ -194,7 +199,10 @@ test("the C2 rule is defined once and reaches first turns, follow-ups and declar
assert.ok(followUpAnswerShapeInstruction().includes(RECTIFICATION_OFFER_RULE));
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:/按纠正规则认下、改读,不提校正/
// 新值:/按改读规则认下、改读,不提校正/
// 原因:BUG-1317,避免「生时纠正」被读成「用户在纠正解读」。
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按改读规则认下、改读,不提校正/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
});
@@ -36,6 +36,19 @@ test("an older body without chart fences keeps its charts, without the moved cav
assert.doesNotMatch(markup, /出生时间敏感/);
});
test("a full-data body that fences every calculation chart drops the trailing section", () => {
const fullData = JSON.parse(readFileSync(new URL("./fixtures/report-full-data-chart-fences.json", import.meta.url), "utf8"));
const fullCharts = assembleLongformCharts(fullData.packet, "ev-cover-longform");
const drawn = fencedReportChartIds(fullData.markdown);
assert.ok(fullCharts.length > 0);
for (const chart of fullCharts) assert.ok(drawn.has(chart.id), chart.id);
assert.equal(renderToStaticMarkup(React.createElement(ReportCalculationCharts, {
charts: fullCharts,
markdown: fullData.markdown,
language: "zh",
})), "");
});
test("the page and the export skip the same charts", () => {
const first = /```jyotish-chart\n[\s\S]*?```/.exec(fixture.markdown)![0];
const drawn = [...fencedReportChartIds(first)];
+34 -1
View File
@@ -107,7 +107,13 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
strip_han_plural_s,
)
text = _sanitize_pl9_public_markdown(markdown)
try:
from pl9_language_terms import translate_zh_glued_phrases
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import translate_zh_glued_phrases
# Whole phrases and glued planet+dignity tokens before shorter word swaps.
# Chapters appended after the parity strip still pass through here.
text = translate_zh_glued_phrases(_sanitize_pl9_public_markdown(markdown))
replacements = {
"# PL9 对标主册": f"# {title}",
"出生资料与图盘(PL9 p1-p17)": "出生资料与图盘",
@@ -1264,6 +1270,27 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
# Body residue (BUG-1312). Parenthetical Sanskrit names (nakshatra, yoga,
# Dasha) and header abbreviations (SAV, BAV, KP, D1-D60) are separated from
# Han by parentheses or spaces, so they are not "glued". They are not function
# words. Chart fences are structured payload and are not part of the prose count.
_ZH_GLUE_RE = re.compile(r"[\u4e00-\u9fff][A-Za-z]{3,}|[A-Za-z]{3,}[\u4e00-\u9fff]")
_ZH_FUNCTION_WORD_RE = re.compile(
r"(?<![A-Za-z])(?:of|in|the|and|by|from|with|listed|background|native|condition|met|satisfied|occupied)(?![A-Za-z])",
re.IGNORECASE,
)
_ZH_CHART_FENCE_RE = re.compile(r"```jyotish-chart\n.*?\n```", re.DOTALL)
def _zh_body_residue(markdown: str) -> dict[str, int]:
body = (markdown or "").split("## 结构化资料附录", 1)[0]
body = _ZH_CHART_FENCE_RE.sub("", body)
return {
"glued_latin": len(_ZH_GLUE_RE.findall(body)),
"function_words": len(_ZH_FUNCTION_WORD_RE.findall(body)),
}
def _pl9_markdown_hygiene_receipt(markdown: str, *, language: str) -> dict[str, Any]:
"""Check the Markdown before rendering user-facing PDF."""
@@ -1327,6 +1354,12 @@ def _pl9_markdown_hygiene_receipt(markdown: str, *, language: str) -> dict[str,
chinese_chars = len(re.findall(r"[\u4e00-\u9fff]", markdown))
if chinese_chars:
violations.append(f"chinese_characters:{chinese_chars}")
if language == 'zh':
residue = _zh_body_residue(markdown)
if residue["glued_latin"]:
violations.append(f"glued_latin:{residue['glued_latin']}")
if residue["function_words"]:
violations.append(f"function_words:{residue['function_words']}")
language_receipt = language_quality_receipt(markdown, language=language)
violations.extend(language_receipt.get("violations") or [])
orphan_lines = orphan_separator_lines(markdown)
+5 -2
View File
@@ -864,11 +864,14 @@ def build_yoga_interpretation_pack(
if name in seen:
continue
seen.add(name)
combination = str(raw.get("combination_en") or raw.get("combination") or raw.get("reason") or "")
if language == "en":
combination = str(raw.get("combination_en") or raw.get("name") or combination)
combination = str(raw.get("combination_en") or raw.get("name") or raw.get("combination") or "")
if re.search(r"[\u4e00-\u9fff]", combination):
combination = "combination text is not listed separately"
else:
# Chinese pages keep the Chinese combination. combination_en is the
# English twin and must not leak into the Chinese report.
combination = str(raw.get("combination") or raw.get("reason") or "")
planets = [str(item) for item in _list(raw.get("planets"))]
overlap = sorted(set(planets) & set(active))
if language == "zh":
+120
View File
@@ -11,6 +11,126 @@ import re
from typing import Any
# Bilingual planet tokens from yoga combination text, longest first.
# Dignity is glued on with no space ("木星Jupiterexalted"), so the long form
# has to be translated before the short planet name.
_BILINGUAL_PLANETS = (
("南交点Ketu", "南交点"),
("北交点Rahu", "北交点"),
("水星Mercury", "水星"),
("木星Jupiter", "木星"),
("金星Venus", "金星"),
("土星Saturn", "土星"),
("火星Mars", "火星"),
("月亮Moon", "月亮"),
("太阳Sun", "太阳"),
)
_GLUED_DIGNITY = {
"exalted": "入旺",
"debilitated": "落陷",
"moolatrikona": "本垣",
"own": "入庙",
"strong": "强",
}
_BILINGUAL_PLANET_RE = re.compile(
"(" + "|".join(re.escape(raw) for raw, _public in _BILINGUAL_PLANETS) + ")"
+ "(exalted|debilitated|moolatrikona|own|strong)?"
)
_BILINGUAL_PLANET_ZH = dict(_BILINGUAL_PLANETS)
# Whole formation sentences and status tokens. Longer phrases stay above the
# short-word glossary so "of" / "in" are not left stuck to Chinese.
_ZH_FORMATION_PHRASES = (
(
"| Bhava Number | Bhava Arambha / House beginning | Bhava Madhya / Middle of House | Bhava Antya / House ending |",
"| 宫位编号 | 宫位起点 | 宫位中点 | 宫位终点 |",
),
(
"Lord of sign occupied in navamsa by 2nd/5th/11th lord, exalted and joins the 9th lord",
"第2、第5或第11宫主在九分盘所落星座的宫主入旺,并与第9宫主会合",
),
(
"Lord of navamsa occupied by 4th lord in 12th",
"第4宫主在九分盘所落星座的宫主位于第12宫",
),
("Learned, eloquent, wealthy from knowledge", "有学识,善表达,学问带来资源"),
("Loss or destruction of property/house", "房屋或产业有损耗"),
(
"Lagna lord, his dispositor, the latter's dispositor in rasi and navamsa, all in kendras/trines/exaltation",
"上升主星、它的定位星,以及定位星的定位星,在本命盘和九分盘都落在角宫、三方宫或入旺",
),
("Fulfills all desires, great king, supreme wealth", "愿望容易得到回应,传统上对应较高的地位与丰厚资源"),
("Eloquent, skilled in debate, good communicator", "善表达,辩论清楚,沟通能力强"),
("Enjoyment of food, pleasure in dining, hospitality", "饮食安稳,待客从容"),
("Courage, initiative, status and practical drive", "有行动力,做事务实"),
("Fear of deception, theft; deceptive nature", "容易担心受骗或失物,需要再核对事实"),
(
"Lagna malefic/Gulika in trine OR Gulika with Kendra/Trine lords OR L1 with Rahu/Sat/Ket",
"凶星或吉利伽在三方宫,或吉利伽与角宫、三方宫主同在,或上升主与罗睺、土星、计都同在",
),
("或BVR精神扰动变体", "或(BVR)精神扰动变体"),
("very_rare", "极少"),
("Not supplied by source", "源数据未提供"),
("Crux of Vedic Astrology", "《吠陀占星精要》"),
("Viparita Raja Yoga", "颠倒王者格"),
("Dhana Yoga", "财富瑜伽"),
("Raj Yoga", "王者格"),
("Custom condition met", "满足自定义条件"),
("custom condition satisfied", "满足自定义条件"),
("自定义条件满足", "满足自定义条件"),
("hemmed in by malefics", "被凶星夹制"),
("hemmed in by benefics", "被吉星夹制"),
("occupied in navamsa", "在九分盘所落"),
("occupied in navamsha", "在九分盘所落"),
("Mutual aspect", "互照"),
("mutual aspect", "互照"),
("drig_rath_aspect_sequence_v1", "Drig 相位顺序 v1"),
("Ashtakavarga八分法(BPHS/PVR书例校准v2.1)", "Ashtakavarga 八分法(BPHS / PVR 书例校准 v2.1)"),
("Sade Sati阶段框架", "Sade Sati 阶段框架"),
("灵魂星AK", "灵魂星(AK)"),
("事业星AmK", "事业星(AmK)"),
("兄弟星BK", "兄弟星(BK)"),
("母亲星MK", "母亲星(MK)"),
("子女星PK", "子女星(PK)"),
("障碍星GK", "障碍星(GK)"),
("配偶星DK", "配偶星(DK)"),
("父亲星PiK", "父亲星(PiK)"),
)
def translate_zh_glued_phrases(text: str) -> str:
"""Translate whole bilingual tokens before any shorter English word."""
if not text:
return text
def planet(match: re.Match) -> str:
name = _BILINGUAL_PLANET_ZH[match.group(1)]
dignity = _GLUED_DIGNITY.get(match.group(2) or "", "")
return name + dignity
text = _BILINGUAL_PLANET_RE.sub(planet, text)
for raw, public in _ZH_FORMATION_PHRASES:
text = text.replace(raw, public)
# House-lord combinations are printed as H6_Lord(Venus) in H12. Translate
# the whole pattern before the short word "in" is left beside Chinese.
text = re.sub(
r"H(\d+)_Lord\(([^)]+)\) in H(\d+)",
r"第\1宫主(\2)在第\3宫",
text,
)
text = re.sub(r"H(\d+)_Lord\(([^)]+)\)", r"第\1宫主(\2)", text)
# Status tokens printed as bare English words. Longer phrases above already
# consumed "not listed" / "Custom condition met", so these are the leftovers.
for raw, public in (
("listed", "已列出"),
("background", "背景"),
("native", "本命"),
):
text = re.sub(rf"(?<![A-Za-z]){raw}(?![A-Za-z])", public, text)
return text
def replace_natural_language_term(text: str, raw: str, public: str) -> str:
"""Replace one glossary term in prose only.
+23
View File
@@ -35,6 +35,29 @@ _PHRASES: dict[str, str] = {
"特殊上升与派生点补充": "Special Lagnas and Points (supplement)",
"敏感点与特殊落点": "Sensitive Points and Special Placements",
"本命与分盘北印度图盘": "Natal and Divisional Charts (North Indian)",
# Chart fences in the parity volume. The Chinese label is the key; the
# English edition looks these up instead of keeping a second title list.
"D1 本命盘": "D1 natal chart",
"月亮盘": "Moon chart",
"D2 财富分盘": "D2 wealth chart",
"D3 手足分盘": "D3 siblings chart",
"D4 居所分盘": "D4 home chart",
"D5 声望分盘": "D5 reputation chart",
"D6 压力分盘": "D6 pressure chart",
"D7 子女分盘": "D7 children chart",
"D8 变化分盘": "D8 change chart",
"D9 九分盘": "D9 navamsha",
"D10 事业分盘": "D10 career chart",
"D11 收益分盘": "D11 gains chart",
"D12 父母分盘": "D12 parents chart",
"D16 享受分盘": "D16 comforts chart",
"D20 灵修分盘": "D20 practice chart",
"D24 学习分盘": "D24 learning chart",
"D27 内在分盘": "D27 inner chart",
"D30 困难分盘": "D30 difficulty chart",
"D40 母系分盘": "D40 maternal chart",
"D45 父系分盘": "D45 paternal chart",
"D60 深层分盘": "D60 deep chart",
"分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。":
"Divisional charts are sensitive to birth time. The raw calculations are here for checking and do not by themselves make a conclusion more certain.",
" 数值位置表": " Position Table",
+188 -3
View File
@@ -111,7 +111,11 @@ def _pl9_report_language(packet: dict) -> str:
def _strip_pl9_user_engineering_markers(markdown: str) -> str:
"""Remove internal audit/profile vocabulary from user-facing report text."""
text = markdown
try:
from scripts.pl9_language_terms import translate_zh_glued_phrases
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from pl9_language_terms import translate_zh_glued_phrases
text = translate_zh_glued_phrases(markdown)
replacements = {
"parameter_sensitive / unverified": "参考",
"parameter_sensitive": "参考",
@@ -481,6 +485,149 @@ def _render_pl9_user_markdown(packet: dict) -> str:
return cleaned
# Drawable charts in the parity "north Indian charts" section. Bhava (Sripati)
# has no chart id (its numbers stay in the Sripati table). Navamsha is not
# listed again: it is the same id as D9. English titles live in
# pl9_reader_english_terms, keyed by these Chinese labels.
_PARITY_CHARTS = (
("D1", "D1 本命盘", "d1"),
("MOON", "月亮盘", "moon"),
("D2", "D2 财富分盘", ("D2_Hora", "D2")),
("D3", "D3 手足分盘", ("D3_Drekkana", "D3")),
("D4", "D4 居所分盘", ("D4_Turyamsa", "D4_Chaturthamsa", "D4")),
("D5", "D5 声望分盘", ("D5_Panchamsa", "D5")),
("D6", "D6 压力分盘", ("D6_Shashthamsa", "D6")),
("D7", "D7 子女分盘", ("D7_Saptamsa", "D7")),
("D8", "D8 变化分盘", ("D8_Ashtamsa", "D8")),
("D9", "D9 九分盘", ("D9_Navamsa", "D9")),
("D10", "D10 事业分盘", ("D10_Dasamsa", "D10")),
("D11", "D11 收益分盘", ("D11_Rudramsa", "D11")),
("D12", "D12 父母分盘", ("D12_Dwadashamsa", "D12_Dvadasamsa", "D12")),
("D16", "D16 享受分盘", ("D16_Shodasamsa", "D16")),
("D20", "D20 灵修分盘", ("D20_Vimsamsa", "D20")),
("D24", "D24 学习分盘", ("D24_Siddhamsa", "D24_Chaturvimsamsa", "D24")),
("D27", "D27 内在分盘", ("D27_Bhamsa", "D27")),
("D30", "D30 困难分盘", ("D30_Trimsamsa", "D30")),
("D40", "D40 母系分盘", ("D40_Khavedamsa", "D40")),
("D45", "D45 父系分盘", ("D45_Akshavedamsa", "D45")),
("D60", "D60 深层分盘", ("D60_Shashtyamsa", "D60_Shashtiamsa", "D60")),
)
def _parity_chart_title(zh: str, language: str) -> str:
if language != "en":
return zh
try:
from scripts.pl9_reader_english_terms import PHRASES_EN
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from pl9_reader_english_terms import PHRASES_EN
return dict(PHRASES_EN).get(zh, zh)
def _parity_chart_source(kind, core_chart, worksheets, divisional, varga_full):
if kind == "d1":
planets = core_chart.get("planets") if isinstance(core_chart.get("planets"), dict) else {}
ascendant = core_chart.get("ascendant") if isinstance(core_chart.get("ascendant"), dict) else {}
return planets, ascendant
if kind == "moon":
d1_sheet = worksheets.get("d1_rasi_bhava") if isinstance(worksheets.get("d1_rasi_bhava"), dict) else {}
moon = d1_sheet.get("moon_chart") if isinstance(d1_sheet.get("moon_chart"), dict) else {}
if not moon and isinstance(divisional.get("moon_chart"), dict):
moon = divisional["moon_chart"]
raw = moon.get("raw") if isinstance(moon.get("raw"), dict) else {}
if not raw and isinstance(moon.get("data"), dict):
raw = moon["data"]
if not raw:
raw = moon
if isinstance(raw.get("raw"), dict):
raw = raw["raw"]
ascendant = {}
for key in ("Ascendant", "ascendant", "Asc"):
if isinstance(raw.get(key), dict):
ascendant = raw[key]
break
planets = raw.get("planets") if isinstance(raw.get("planets"), dict) else {}
return planets, ascendant
chart = {}
for key in kind:
candidate = varga_full.get(key) if isinstance(varga_full, dict) else None
if isinstance(candidate, dict) and candidate:
chart = candidate
break
ascendant = {}
for key in ("Ascendant", "ascendant", "Asc"):
if isinstance(chart.get(key), dict):
ascendant = chart[key]
break
return chart, ascendant
def _append_parity_chart_fences(lines, *, language, core_chart, worksheets, divisional, varga_full) -> None:
"""One north-Indian fence per chart that has a sign to draw. No empty heading."""
try:
from scripts.report_chart_block import PLANET_ORDER, build_chart_block
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_chart_block import PLANET_ORDER, build_chart_block
natal = core_chart.get("planets") if isinstance(core_chart.get("planets"), dict) else {}
retrograde = {
name: bool(row.get("retrograde")) if isinstance(row, dict) else False
for name in PLANET_ORDER
for row in [natal.get(name)]
}
seen: set[str] = set()
for chart_id, title_zh, kind in _PARITY_CHARTS:
if chart_id in seen:
continue
positions, ascendant = _parity_chart_source(kind, core_chart, worksheets, divisional, varga_full)
title = _parity_chart_title(title_zh, language)
try:
block = build_chart_block(chart_id, title, positions, ascendant, retrograde)
except (TypeError, ValueError):
continue
seen.add(chart_id)
lines.extend([f"##### {title}", "", block, ""])
def _ascendant_report_fields(asc: dict) -> dict:
"""Longitude, nakshatra and lords for the report's ascendant row. No new dignity."""
longitude = None
for key in ("lon", "longitude", "degree_raw"):
value = asc.get(key)
if isinstance(value, (int, float)):
longitude = float(value) % 360.0
break
if longitude is None:
sign = asc.get("sign")
degree = asc.get("degree_in_sign")
if not isinstance(degree, (int, float)):
degree = asc.get("degree")
signs = globals().get("SIGNS") or []
if sign in signs and isinstance(degree, (int, float)):
longitude = signs.index(sign) * 30.0 + float(degree)
fields = {}
if longitude is not None:
try:
from scripts.jyotish_engine import _nakshatra_fields
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from jyotish_engine import _nakshatra_fields
fields = _nakshatra_fields(longitude)
degree = asc.get("degree_in_sign")
if not isinstance(degree, (int, float)):
degree = asc.get("degree")
pada = fields.get("nakshatra_pada") if fields else None
if pada is None:
pada = asc.get("nakshatra_pada") or asc.get("pada")
return {
"sign": asc.get("sign"),
"degree": degree,
"longitude": longitude,
"nakshatra": fields.get("nakshatra") or asc.get("nakshatra"),
"pada": pada,
"nakshatra_lord": fields.get("nakshatra_lord") or asc.get("nakshatra_lord"),
"sign_lord": asc.get("lord") or asc.get("rasi_lord"),
}
def render_pl9_parity_markdown(packet: dict) -> str:
"""Render only PL9 page-family material admitted to the parity main volume."""
_bind_engine_symbols()
@@ -544,6 +691,18 @@ def render_pl9_parity_markdown(packet: dict) -> str:
elif isinstance(value, (list, tuple)):
value = ';'.join(str(item) for item in value if item not in (None, '', [], {}))
text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip()
if _lang != 'en':
# Whole status tokens. Longer phrases are handled in the glossary
# before these short words, so a half-translated fragment is not
# cleaned up by deleting letters here.
text = {
'active': '当前运行',
'background': '背景',
'listed': '已列出',
'native': '本命',
'Custom condition met': '满足自定义条件',
'custom condition satisfied': '满足自定义条件',
}.get(text, text)
return text.replace('|', '/')
def _brief(items, limit=2) -> str:
@@ -668,6 +827,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
def _public_status_note(value) -> str:
zh_mapping = {
'partial_verified': '已生成',
'listed': '已列出',
'active': '当前运行',
'background': '背景',
'native': '本命',
'parameter_sensitive': '参数敏感',
'blocked': '需补资料',
'verified': '已核验',
@@ -856,6 +1019,14 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'| Planet | R/C | Sign | Degree | Longitude | House | Nakshatra | Pada | Speed |',
'|--------|-----|------|--------|-----------|-------|-----------|------|-------|',
])
ascendant = _dict(core_chart.get('ascendant'))
if ascendant:
info = _ascendant_report_fields(ascendant)
asc_label = 'Ascendant' if _lang == 'en' else '上升'
lines.append(
f"| {_cell(asc_label)} | - | {_cell(_token(info['sign']))} | {_cell(_degree(info['degree']))} | "
f"{_cell(_degree(info['longitude']))} | 1 | {_cell(info['nakshatra'])} | {_cell(info['pada'])} | - |"
)
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
row = _dict(planets.get(planet))
if not row:
@@ -875,6 +1046,14 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'| 行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比 |',
'|------|--------|--------|------|--------|----------|--------|',
])
if ascendant:
info = _ascendant_report_fields(ascendant)
asc_label = 'Ascendant' if _lang == 'en' else '上升'
dignity = 'not applicable' if _lang == 'en' else '不适用'
lines.append(
f"| {_cell(asc_label)} | {_cell(_token(info['sign_lord']))} | {_cell(_token(info['nakshatra_lord']))} | "
f"- | - | {_cell(dignity)} | - |"
)
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
row = _dict(planets.get(planet))
if not row:
@@ -1078,8 +1257,14 @@ def render_pl9_parity_markdown(packet: dict) -> str:
ready_natal_specs = [spec for spec in natal_specs if spec[2]]
if ready_natal_specs:
lines.extend(['### p6-p17 本命与分盘北印度图盘', '', '分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。', ''])
for page, label, _ready in ready_natal_specs:
lines.extend([f'##### p{page} · {label}', ''])
_append_parity_chart_fences(
lines,
language=_lang,
core_chart=core_chart,
worksheets=worksheets,
divisional=divisional,
varga_full=varga_full,
)
# The original PL9 charts are visual, but their sign placements are
# still part of the visible evidence. Keep the chart drawing and add
# a compact numeric transcription so an AI reader can recover the
+7 -1
View File
@@ -62,6 +62,7 @@ def test_reader_report_karaka_table_is_in_bphs_order() -> None:
import copy
from scripts.jaimini import KARAKA_CN
from scripts.pl9_language_terms import translate_zh_glued_phrases
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from tests.test_report_english_edition import CASES, _packet
@@ -70,4 +71,9 @@ def test_reader_report_karaka_table_is_in_bphs_order() -> None:
lines = markdown.splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("### Jaimini Karaka"))
rows = [line for line in lines[start + 1:start + 14] if line.startswith("| ") and "星" in line.split("|")[1]]
assert [row.split("|")[1].strip() for row in rows] == [KARAKA_CN[role] for role in BPHS_8]
# 原值:[KARAKA_CN[role] for role in BPHS_8](灵魂星AK、事业星AmK、兄弟星BK、母亲星MK、父亲星PiK、子女星PK、障碍星GK、配偶星DK)
# 新值:[translate_zh_glued_phrases(KARAKA_CN[role]) for role in BPHS_8](灵魂星(AK)、事业星(AmK)、兄弟星(BK)、母亲星(MK)、父亲星(PiK)、子女星(PK)、障碍星(GK)、配偶星(DK))
# 原因:完整数据版正文会给这八个星位名的缩写加括号,避免三字母缩写被粘连检查判中。KARAKA_CN 仍是原字面值,咨询和星盘页继续用它。
assert [row.split("|")[1].strip() for row in rows] == [
translate_zh_glued_phrases(KARAKA_CN[role]) for role in BPHS_8
]
+108
View File
@@ -150,6 +150,114 @@ def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> Non
assert row[5].isdigit()
_CALCULATION_CHART_IDS = {
"D1", "D2", "D3", "D4", "D7", "D9", "D10", "D11", "D12",
"D16", "D20", "D24", "D27", "D30", "D40", "D60",
}
def _section_until_next_h2(markdown: str, heading: str) -> str:
start = markdown.index(heading)
lines = [heading]
for line in markdown[start + len(heading):].splitlines():
if line.startswith("## "):
break
lines.append(line)
return "\n".join(lines)
def test_full_data_chart_section_draws_each_ready_chart_once(editions) -> None:
from scripts.report_chart_block import FENCE_RE
zh, en, _packet = editions
for markdown, heading in (
(zh, "### 本命与分盘北印度图盘"),
(en, "### Natal and Divisional North Indian Charts"),
):
section = _section_until_next_h2(markdown, heading)
blocks = []
for match in FENCE_RE.finditer(section):
block = json.loads(match.group(1))
assert block["layout"] == "north"
blocks.append(block)
ids = [block["id"] for block in blocks]
assert ids
assert len(ids) == len(set(ids))
assert _CALCULATION_CHART_IDS <= set(ids)
assert "MOON" in ids
assert not re.search(r"(?m)^##### .+\n\n+##### ", section)
assert not re.search(r"(?m)^##### .*Bhava", section)
assert "##### Navamsha" not in section and "##### p7 · Navamsha" not in section
def _heading_table(markdown: str, heading: str) -> list[list[str]]:
"""Like _table_after, but the lord table is a #### heading, not ###."""
lines = markdown.splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("#") and heading in line)
rows = []
for line in lines[start + 1:]:
if line.startswith("#") or (rows and not line.startswith("|")):
break
if line.startswith("|") and not set(line) <= set("|- "):
rows.append([cell.strip() for cell in line.strip("|").split("|")])
return rows
def test_birth_position_table_starts_with_the_ascendant(editions) -> None:
zh, en, packet = editions
asc = packet["core_chart"]["ascendant"]
longitude = asc.get("lon") if isinstance(asc.get("lon"), (int, float)) else asc.get("degree_raw")
fields = _nakshatra_fields(float(longitude))
lord_zh = {
"Sun": "太阳", "Moon": "月亮", "Mars": "火星", "Mercury": "水星",
"Jupiter": "木星", "Venus": "金星", "Saturn": "土星",
"Rahu": "北交点", "Ketu": "南交点",
}
zh_rows = _heading_table(zh, "行星原始位置")
assert zh_rows[1][0] == "上升"
assert fields["nakshatra"] in zh_rows[1][6]
assert zh_rows[1][7] == str(fields["nakshatra_pada"])
assert zh_rows[1][5] == "1"
lords = _heading_table(zh, "行星主星与状态")
assert lords[1][0] == "上升"
assert lords[1][5] == "不适用"
assert lords[1][2] in {fields["nakshatra_lord"], lord_zh.get(fields["nakshatra_lord"])}
en_rows = _heading_table(en, "Planetary Raw Positions")
assert en_rows[1][0] in {"Ascendant", "Lagna", "上升"}
assert fields["nakshatra"] in en_rows[1][6]
assert en_rows[1][7] == str(fields["nakshatra_pada"])
def test_zh_hygiene_rejects_glued_english(editions) -> None:
from scripts.pl9_full_data_export import _pl9_markdown_hygiene_receipt, _zh_body_residue
injected = _pl9_markdown_hygiene_receipt("木星Jupiterexalted在巨蟹座\n", language="zh")
assert injected["status"] == "fail"
assert any(str(item).startswith("glued_latin:") for item in injected["violations"])
residue = _zh_body_residue(editions[0])
assert residue == {"glued_latin": 0, "function_words": 0}
def test_zh_hygiene_is_clean_on_night_and_a_public_chart() -> None:
from scripts.pl9_full_data_export import _zh_body_residue
# Public birth used by tests/smoke_test_runner.py. Not a private user.
public = {
**CASES["day"],
"name": "Albert Einstein",
"year": 1879,
"month": 3,
"day": 14,
"hour": 11,
"minute": 30,
"lat": 48.4,
"lon": 9.98,
"tz": 0.89,
"age": 76,
}
for case in (CASES["night"], public):
packet = _packet(case)
markdown = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "full_data")
assert _zh_body_residue(markdown) == {"glued_latin": 0, "function_words": 0}
def test_chart_section_carries_the_divisional_caveat(editions) -> None:
zh, en, _ = editions
assert re.search(r"### 本命与分盘北印度图盘\n\n" + re.escape(CAVEAT_ZH) + r"\n", zh)
+1 -1
View File
@@ -10,7 +10,7 @@ Claude(开发机,Linux)负责规划与验收,Grok Build(产品负责
| --- | --- | --- |
| `agent/待规划` | Claude:诊断 → 任务书推 staging | `agent/待执行` 或 `agent/需拍板` |
| `agent/待执行` | Grok:按任务书在 `codex/*` 实现、自测、推分支 | `agent/待验收` 或 `agent/需拍板` |
| `agent/待验收` | Claude:只验 Grok 回报的 SHA | 通过 → 推 staging → `agent/部署中`;不通过 → 修复单 → `agent/待执行`(打回 2 次 → `agent/需拍板`) |
| `agent/待验收` | Claude:只验 Grok 回报的 SHA | 通过 → 推 staging → `agent/部署中`;不通过 → 修复单 → `agent/待执行`(打回 2 次 → `agent/需拍板`);只是落后于 staging 且合并只碰文档时,Claude 自己合并,不打回、不计次数(2026-10-13 产品决定) |
| `agent/部署中` | Claude 脚本(不用模型):核对 `/api/health` | `agent/待真机`;3 小时未部署 → `agent/需拍板` |
| `agent/待真机` | 产品负责人 | 关 issue,或评论问题并改回 `agent/待规划` |
| `agent/需拍板` | 产品负责人 | 回复后改回原状态 |
+1 -1
View File
@@ -8,7 +8,7 @@
## 要做的
1. `git fetch origin`,`git checkout --detach <extra.sha>`。确认 `git merge-base --is-ancestor origin/staging HEAD` 成立(分支基于最新 staging 可快进);不成立则判 `fail`,修复单要求 Grok 在最新 staging 上重做。
1. `git fetch origin`,`git checkout --detach <extra.sha>`。确认 `git merge-base --is-ancestor origin/staging HEAD` 成立(分支基于最新 staging 可快进);不成立时先试 `git merge --no-ff origin/staging`:若无冲突,或冲突**只在 `docs/**`、`CHANGELOG*.md` 等纯文档里**(如两单都往 `docs/BUG_HISTORY.md` 末尾加条目),由你解决冲突、提交合并(提交信息写明「只碰文档」),核对 `scripts/`、`tests/`、`frontend/` 等代码路径与被验 SHA 的树完全相同,再按下面验收并推送,**不打回、不计打回次数**(2026-10-13 产品负责人在 #53 选定方案 A)。冲突碰到任何代码文件则判 `fail`,修复单要求 Grok 在最新 staging 上重做(Grok 不能改写已推分支,用 merge 而不是 rebase)。
2. 对照任务书逐条给出 **通过 / 未通过 / 环境缺口**。验收命令按 `CLAUDE.md`「验收口径」与 `AGENTS.md` §7、§10 跑,前端全量 `npm test` 与 Python 快速门以你这里为准。依赖缺失时先在本工作目录 `npm ci --prefix frontend`;Python 用 `.venv/bin/python`,没有就用 `python3`。
3. `test:db` 你这里跑不了:以 Grok 回报为准,但要核对它贴的失败清单与 `tools/agent-loop/windows-known-failures.txt` 逐条一致;对不上即未通过。
4. 检查越界:diff 里有没有任务书没要求的文件、`.gitea/**`、依赖升级、冻结文件增长、弱化断言(缺"原值 / 新值 / 原因"三栏)、隐私数据。有就 `fail`。