Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccceff91f | ||
|
|
0c04755560 |
+4
-4
@@ -1,10 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-13 — 报告开头的「重点」改成一张摘要卡(待合入 staging)
|
||||
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
|
||||
|
||||
- 新生成的个人报告,标题比正文大,下面一行小字是名字和生成日期。没有名字只写日期;标题里已经有名字就不重复。
|
||||
- 开头不再堆四句行话。第一行是现在走的大运(主运、子运、次子运和起止年月),下面三行是事业、财富、婚恋,各带两个小标签。最后仍提醒:这段时间可能突出这些主题,不是确定会发生的事。
|
||||
- 导出的文件里这一块是文字,不是一串代码。以前生成的报告打开还是原来的样子。Skill 版本不变(BUG-1316)。
|
||||
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
|
||||
- 问事业、感情这类普通问题,仍是先排盘再回答。只是觉得解读不准,仍是认下来改着读,不会被拉去做校正。
|
||||
- Skill 版本不变(BUG-1317,是 BUG-1306 的复发)。
|
||||
|
||||
## 2026-10-13 — 新报告正文画出分盘,中文更干净,上升行和同位列能读懂(待合入 staging)
|
||||
|
||||
|
||||
+14
-11
@@ -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(父母静态题研究)。
|
||||
|
||||
@@ -17689,17 +17690,19 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1316 | 报告开头的「重点」没有设计,标题也没有样式
|
||||
## BUG-1317 | 明说要做生时校正时,仍先排完盘再写解读,校正没打开
|
||||
|
||||
- 状态:fixed-pending-verify(须重新生成报告才看得到;真机清单未走,不标 resolved)
|
||||
- 状态:fixed-pending-verify(真机清单未走,没有模型凭据,不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
|
||||
- 来源:issue #55。产品负责人选 A:做成一张摘要卡,标题下写名字和日期,保留提醒句。
|
||||
- 现象:新报告开头先有一行「重点:当前主运 MD …」,接着又有「## 重点」和三条「事业 / 财富 / 婚恋」数据句,目录里再出现一次「重点」。标题和正文一样大。缩写 MD / AD / PD 直接给读者看。
|
||||
- 根因:10-08 只要求开篇有这段内容,没有规定怎么呈现。09-29 去掉封面之后,标题只剩字体。检索「重点:」「opening_focus」「当前主运 MD」当时没有记录。不是旧修复回退。
|
||||
- 修复:开篇改成一个 `jyotish-focus` 围栏。阅读页解析后画一张卡:现在走的大运、事业、财富、婚恋,尊贵和功能吉凶是两个小标签,最后是原来的提醒句。拿不到的日期写空,不新算大运或宫主。标题用显示字体,比下一章大;下面一行是名字和生成日期。名字只出现在本人打开的阅读页,公开投影和导出都不带名字。没有围栏的旧报告照旧显示。导出把围栏换成可读的字。
|
||||
- 验证:`tests/test_report_full_data_fix.py` 10 通过。另四份报告测试退出码 0。前端改到的测试 147 通过、0 失败、0 取消,其中导出冻结哈希仍在。`tsc --noEmit` 退出码 0。`npm run lint` 0 error。
|
||||
- 防复发:`tests/test_report_full_data_fix.py` 的开篇围栏用例;`frontend/tests/report-focus-card.test.ts` 用虚构日盘真实输出锁住卡片、坏数据和旧正文。
|
||||
- 相关记录:无。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/report-focus-card-20261013`,尚未合入 staging。
|
||||
- 来源: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 和默认模型各走一遍。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 进度 · 报告开篇重点卡(2026-10-13)
|
||||
|
||||
- 分支:`codex/report-focus-card-20261013`
|
||||
- 任务书:`docs/tasks/TASK-report-focus-card-20261013.md`
|
||||
- 基线:任务书写的 `856bc850b` 已过时。本轮按 Claude 计划,从当时的 `origin/staging` `963276ca2fc942bc28ef9bdee3b8d0e9d3c957c0` 建分支,没有回退。
|
||||
- issue:#55,BUG-1316
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 新报告开篇改成一个 `jyotish-focus` 围栏。里面是现在的主运、子运、次子运,以及事业(第 10 宫)、财富(第 2 宫)、婚恋(第 7 宫)。日期和宫主都用报告里已经有的数,缺的写空。
|
||||
2. 阅读页把这个围栏画成一张卡。中文不出现 MD / AD / PD。提醒句留在卡的最后。围栏坏了只显示「重点数据无效」。
|
||||
3. 标题比下一章大。下面一行是「名字 · 生成于 日期」。没有名字只写日期。标题里已经有名字就不重复。名字只给登录后打开自己报告的人,公开页和导出文件都不带名字。
|
||||
4. 旧报告没有这个围栏时,正文照旧。导出把围栏换成可读的字。
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 开篇围栏 | `.venv\Scripts\python -m pytest tests/test_report_full_data_fix.py -q` | 10 通过,退出码 0 |
|
||||
| 相关报告测试 | 同解释器跑 chart blank columns、english edition、reader appendix、reader main | 退出码 0 |
|
||||
| 前端改到的测试 | `node node_modules/tsx/dist/cli.mjs --test` 九个相关文件 | 147 通过,0 失败,0 取消 |
|
||||
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| 前端 lint | `frontend` 下 `npm run lint` | 0 error(125 条既有 warning,本轮未改) |
|
||||
| 数据库 | 未跑 | 本轮不改表,任务书写明不需要 |
|
||||
|
||||
## 偏离
|
||||
|
||||
- 中文报告大运章的表头仍有 MD / AD / PD。卡片和开篇里没有。英文测试仍要求正文里能看到 AD、PD,所以没有改那一章的表头。任务书「全文不再出现」按开篇(出生资料之前)执行。
|
||||
- 固定清单「先核对出生资料、图盘」原来也会被写进资料附录。附录单独整理时不再注入这张清单。正文开头有重点卡时也不再注入。
|
||||
- 短报告测试要显式画出第 1 章,卡片才会出现在静态结果里。正式阅读页里,完整数据版第 1 章本来就会画出来。没有把「重点」改成一打开就全部展开。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 真机清单 `docs/testing/report-focus-card-20261013.md` 留给部署后。本机没有登录态,没有重开浏览器看深浅色。
|
||||
- 未跑前端全量测试、Python 快速门、`next build`。按任务书留给验收方。
|
||||
@@ -211,6 +211,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `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` |
|
||||
|
||||
@@ -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`。
|
||||
@@ -5,6 +5,7 @@
|
||||
| 步 | 怎么做 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 |
|
||||
| 1b | 新对话第一句:「请你帮我生时纠正」(不带「进行」) | 同样直接打开校正,不先出「已完成几步」再写解读。BUG-1317 |
|
||||
| 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 |
|
||||
| 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 |
|
||||
| 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 |
|
||||
@@ -12,5 +13,6 @@
|
||||
| 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 |
|
||||
| 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 |
|
||||
| 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 |
|
||||
| 9 | 新对话问「看看我今年事业」 | 照常先排盘再解读,不出现校正,下面没有提议卡 |
|
||||
|
||||
做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。
|
||||
|
||||
@@ -675,8 +675,6 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
|
||||
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
|
||||
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
|
||||
- **报告标题 (2026-10-13, BUG-1316):** the Markdown `h1` is the report title. It uses `--font-display` at `--type-display-md` (larger than the `h2` `--type-display-sm`), weight 500, with `--space-2` above and `--space-3` below. Under it, one caption (`.personal-report-md-byline`, `--color-ink-tertiary`, `--type-caption`, body face) reads `{名字} · 生成于 YYYY-MM-DD`, or `Generated` in English. No name: date only. The title already contains the name: date only, the name is not repeated. The name comes from the signed-in read (`subjectDisplayName`); it is not added to the public projection or the export. Old reports without a name still show the date.
|
||||
- **开篇重点卡 (2026-10-13, BUG-1316):** the first chapter is one card (`.personal-report-focus-card`) drawn from a `jyotish-focus` fence. The engine writes the JSON; the page only parses it. First row, 「现在走的大运」: 主运 / 子运 / 次子运, each the planet's Chinese name plus `YYYY.MM – YYYY.MM`, in a wrapping row. Then 事业 / 财富 / 婚恋: 「第 N 宫主 · 行星 · 落第 M 宫」 and two tags (`.personal-report-focus-tag`, same muted pill as `.chart-role-badge`: `--color-canvas-muted`, `--color-ink-secondary`, `--type-overline`). The last line is the caveat at `--type-caption` / `--color-ink-tertiary`. No MD / AD / PD on the card. A bad fence says 「重点数据无效」 (English "Focus data is invalid"), the same invalid line as a bad chart, and does not echo the JSON. The card wraps; it does not scroll sideways. Tokens only, so light and dark both stay readable. Reports with no fence render as before. Export turns the fence into those same words; the file has no raw JSON. The public projection keeps a public fence the way it keeps `jyotish-chart`, and does not add a name field.
|
||||
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
|
||||
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
|
||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
|
||||
|
||||
@@ -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`,不写死。
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -4338,79 +4338,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-8) var(--space-7) var(--space-10);
|
||||
}
|
||||
.personal-report-md-article h1 {
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-md);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.personal-report-md-byline {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.personal-report-focus-card {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0 0 var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.personal-report-focus-kicker {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-overline);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.personal-report-focus-periods,
|
||||
.personal-report-focus-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.personal-report-focus-period {
|
||||
min-width: 0;
|
||||
flex: 1 1 8rem;
|
||||
margin: 0;
|
||||
}
|
||||
.personal-report-focus-period strong { display: block; font-weight: 600; }
|
||||
.personal-report-focus-period span {
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.personal-report-focus-domains { display: grid; gap: var(--space-3); }
|
||||
.personal-report-focus-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.personal-report-focus-row p { margin: 0; }
|
||||
.personal-report-focus-domain { display: block; font-weight: 600; }
|
||||
.personal-report-focus-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas-muted);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 600;
|
||||
}
|
||||
.personal-report-focus-caveat {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* The report title (markdown `#`) is the md view's cover line; it takes the
|
||||
heading face like the document view's `.personal-report-cover h1`. */
|
||||
.personal-report-md-article h1 { font-family: var(--font-display); }
|
||||
.personal-report-md-article h2,
|
||||
.personal-report-md-article h3 { scroll-margin-top: 80px; }
|
||||
.personal-report-md-article h2 {
|
||||
|
||||
@@ -6,13 +6,11 @@ import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
|
||||
import { ReportFocusCard } from "@/components/personal-report/report-focus-card";
|
||||
import {
|
||||
chartAriaLabel,
|
||||
parseReportChartBlock,
|
||||
toNorthIndianChart,
|
||||
} from "@/lib/report-chart-block";
|
||||
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
|
||||
@@ -37,14 +35,8 @@ function reportUrlTransform(url: string): string {
|
||||
return url.startsWith("#") ? url : "";
|
||||
}
|
||||
|
||||
function markdownComponents(
|
||||
headings: readonly LongformHeading[],
|
||||
language: ReportLanguage,
|
||||
subjectName?: string,
|
||||
createdAt?: string,
|
||||
): Components {
|
||||
function markdownComponents(headings: readonly LongformHeading[], language: ReportLanguage): Components {
|
||||
const cursor = { h2: 0, h3: 0 };
|
||||
const byline = { shown: false };
|
||||
const nextId = (level: 2 | 3, children: ReactNode) => {
|
||||
const title = flattenText(children).trim();
|
||||
const key = level === 2 ? "h2" : "h3";
|
||||
@@ -66,17 +58,6 @@ function markdownComponents(
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
h1: ({ children }) => {
|
||||
const title = flattenText(children).trim();
|
||||
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
|
||||
byline.shown = true;
|
||||
return (
|
||||
<>
|
||||
<h1>{children}</h1>
|
||||
{line ? <p className="personal-report-md-byline">{line}</p> : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
|
||||
h4: ({ children }) => {
|
||||
@@ -89,15 +70,7 @@ function markdownComponents(
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => {
|
||||
const fence = fencedLanguage(children);
|
||||
if (fence === "jyotish-focus") {
|
||||
const focus = parseReportFocusBlock(fencedSource(children).trim());
|
||||
if (!focus) {
|
||||
return <p className="personal-report-chart-invalid">{language === "en" ? "Focus data is invalid" : "重点数据无效"}</p>;
|
||||
}
|
||||
return <ReportFocusCard block={focus} language={language} />;
|
||||
}
|
||||
if (fence !== "jyotish-chart") {
|
||||
if (fencedLanguage(children) !== "jyotish-chart") {
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
const block = parseReportChartBlock(fencedSource(children).trim());
|
||||
@@ -117,16 +90,10 @@ function markdownComponents(
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(
|
||||
markdown: string,
|
||||
headings: readonly LongformHeading[],
|
||||
language: ReportLanguage,
|
||||
subjectName?: string,
|
||||
createdAt?: string,
|
||||
) {
|
||||
function renderMarkdown(markdown: string, headings: readonly LongformHeading[], language: ReportLanguage) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={markdownComponents(headings, language, subjectName, createdAt)}
|
||||
components={markdownComponents(headings, language)}
|
||||
disallowedElements={["script", "iframe", "object", "embed", "img"]}
|
||||
rehypePlugins={[reportChartGrid]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@@ -158,16 +125,12 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
reveal,
|
||||
language,
|
||||
drawn = false,
|
||||
subjectName,
|
||||
createdAt,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
reveal: RevealSignal;
|
||||
language: ReportLanguage;
|
||||
/** Drawn from the first render: the reader is about to jump to it or past it. */
|
||||
drawn?: boolean;
|
||||
subjectName?: string;
|
||||
createdAt?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [seen, setVisible] = useState(section.eager || drawn);
|
||||
@@ -188,10 +151,10 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
const content = useMemo(
|
||||
() => (visible ? renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt) : (
|
||||
() => (visible ? renderMarkdown(section.markdown, section.headings, language) : (
|
||||
<h2 id={section.id}>{section.title}</h2>
|
||||
)),
|
||||
[visible, section, language, subjectName, createdAt],
|
||||
[visible, section, language],
|
||||
);
|
||||
return (
|
||||
<section ref={ref} className="personal-report-md-section">
|
||||
@@ -248,20 +211,11 @@ function ReportToc({
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonalReportMarkdownView({
|
||||
markdown,
|
||||
language = "zh",
|
||||
drawnThrough = null,
|
||||
subjectName,
|
||||
createdAt,
|
||||
}: {
|
||||
export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThrough = null }: {
|
||||
markdown: string;
|
||||
language?: ReportLanguage;
|
||||
/** Chapter index (h2 order) through which sections render immediately; used when switching editions. */
|
||||
drawnThrough?: number | null;
|
||||
/** Owner-only display name. Omitted from export and from the public projection. */
|
||||
subjectName?: string;
|
||||
createdAt?: string;
|
||||
}) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]);
|
||||
@@ -294,21 +248,21 @@ export function PersonalReportMarkdownView({
|
||||
for (const listener of reveal.listeners) listener();
|
||||
}), [chapters.length, paged, reveal]);
|
||||
const lead = useMemo(
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language, subjectName, createdAt) : null),
|
||||
[outline, language, subjectName, createdAt],
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language) : null),
|
||||
[outline, language],
|
||||
);
|
||||
const safeChapter = Math.min(chapter, Math.max(chapters.length - 1, 0));
|
||||
const currentChapter = chapters[safeChapter];
|
||||
const pagedChapter = useMemo(() => (
|
||||
paged && currentChapter
|
||||
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language, subjectName, createdAt)
|
||||
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language)
|
||||
: null
|
||||
), [paged, currentChapter, language, subjectName, createdAt]);
|
||||
), [paged, currentChapter, language]);
|
||||
const printedChapters = useMemo(() => (
|
||||
paged && printing
|
||||
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt))
|
||||
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language))
|
||||
: null
|
||||
), [paged, printing, chapters, language, subjectName, createdAt]);
|
||||
), [paged, printing, chapters, language]);
|
||||
const openChapter = useCallback((id: string) => {
|
||||
const index = chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id));
|
||||
if (index >= 0) setChapter(index);
|
||||
@@ -364,12 +318,10 @@ export function PersonalReportMarkdownView({
|
||||
{outline.sections.map((section, index) => (section.eager ? null : (
|
||||
<LazyMarkdownSection
|
||||
key={section.id}
|
||||
createdAt={createdAt}
|
||||
drawn={drawnThrough !== null && index <= drawnThrough}
|
||||
language={language}
|
||||
reveal={reveal}
|
||||
section={section}
|
||||
subjectName={subjectName}
|
||||
/>
|
||||
)))}
|
||||
</article>
|
||||
|
||||
@@ -61,8 +61,6 @@ export type ReportLoadState =
|
||||
markdown: string;
|
||||
reportId: string;
|
||||
createdAt: string;
|
||||
/** Signed-in reader's display name. Never written into the export. */
|
||||
subjectDisplayName?: string;
|
||||
factTables?: ReportFactTable[];
|
||||
calculationCharts?: ReportDocumentV2["charts"];
|
||||
/** `legacy`: generated before the English edition existed; no switch is shown. */
|
||||
@@ -147,7 +145,6 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
const facts = reportFactTablesSchema.safeParse(json.factTables);
|
||||
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
|
||||
const english = readEnglishEdition(json);
|
||||
const subjectDisplayName = readSubjectDisplayName(json);
|
||||
return {
|
||||
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
|
||||
...(charts.success ? { calculationCharts: charts.data } : {}),
|
||||
@@ -156,7 +153,6 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
markdown,
|
||||
reportId: typeof view.id === "string" ? view.id : "",
|
||||
createdAt: typeof view.createdAt === "string" ? view.createdAt : "",
|
||||
...(subjectDisplayName ? { subjectDisplayName } : {}),
|
||||
};
|
||||
}
|
||||
return { phase: "legacy-unavailable" };
|
||||
@@ -199,13 +195,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readSubjectDisplayName(json: unknown): string | undefined {
|
||||
if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined;
|
||||
const trimmed = json.subjectDisplayName.trim();
|
||||
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The English edition is trusted only in the shape the route promises and only
|
||||
* when it holds no Han character; anything else reads as unavailable, and the
|
||||
@@ -586,14 +575,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
|
||||
/>
|
||||
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
|
||||
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */}
|
||||
<PersonalReportMarkdownView
|
||||
key={`markdown-${shown}`}
|
||||
createdAt={state.createdAt}
|
||||
drawnThrough={drawnThrough}
|
||||
language={shown}
|
||||
markdown={markdown}
|
||||
subjectName={state.subjectDisplayName}
|
||||
/>
|
||||
<PersonalReportMarkdownView key={`markdown-${shown}`} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
|
||||
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
|
||||
{factTables && <ReportFactTables key={`facts-${shown}`} language={shown} tables={factTables} />}
|
||||
</div>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
periodRange,
|
||||
planetLabel,
|
||||
type ReportFocusBlock,
|
||||
} from "@/lib/report-focus-block";
|
||||
import type { ReportLanguage } from "@/lib/report-language";
|
||||
|
||||
const LEVEL = {
|
||||
md: { zh: "主运", en: "Major" },
|
||||
ad: { zh: "子运", en: "Sub" },
|
||||
pd: { zh: "次子运", en: "Sub-sub" },
|
||||
} as const;
|
||||
|
||||
const DOMAIN = {
|
||||
career: { zh: "事业", en: "Career" },
|
||||
wealth: { zh: "财富", en: "Wealth" },
|
||||
relationship: { zh: "婚恋", en: "Relationship" },
|
||||
} as const;
|
||||
|
||||
export function ReportFocusCard({
|
||||
block,
|
||||
language,
|
||||
}: {
|
||||
block: ReportFocusBlock;
|
||||
language: ReportLanguage;
|
||||
}) {
|
||||
const en = language === "en";
|
||||
return (
|
||||
<section className="personal-report-focus-card">
|
||||
<p className="personal-report-focus-kicker">{en ? "Current period" : "现在走的大运"}</p>
|
||||
<div className="personal-report-focus-periods">
|
||||
{block.periods.map((period) => {
|
||||
const range = periodRange(period.start, period.end);
|
||||
return (
|
||||
<p className="personal-report-focus-period" key={period.level}>
|
||||
<strong>{LEVEL[period.level][language]} · {planetLabel(period.lord, language)}</strong>
|
||||
{range ? <span>{range}</span> : null}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="personal-report-focus-domains">
|
||||
{block.domains.map((domain) => {
|
||||
const name = planetLabel(domain.lord, language);
|
||||
const placed = domain.placed === null
|
||||
? (en ? "house not listed" : "落宫未列出")
|
||||
: (en ? `house ${domain.placed}` : `第${domain.placed}宫`);
|
||||
const facts = en
|
||||
? `House ${domain.house} lord · ${name} · ${placed}`
|
||||
: domain.placed === null
|
||||
? `第${domain.house}宫主 · ${name} · ${placed}`
|
||||
: `第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
|
||||
return (
|
||||
<div className="personal-report-focus-row" key={domain.key}>
|
||||
<p>
|
||||
<span className="personal-report-focus-domain">{DOMAIN[domain.key][language]}</span>
|
||||
<span>{facts}</span>
|
||||
</p>
|
||||
<p className="personal-report-focus-tags">
|
||||
<span className="personal-report-focus-tag">{domain.dignity}</span>
|
||||
<span className="personal-report-focus-tag">{domain.role}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="personal-report-focus-caveat">{block.caveat}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { stripReportChartBlocks } from "./report-chart-block";
|
||||
import { renderReportFocusFences } from "./report-focus-block";
|
||||
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
|
||||
|
||||
/** Historical ordinary export contract, retained as the full-body byte regression lock. */
|
||||
export function ordinaryReportDownloadMarkdown(markdown: string): string {
|
||||
return renderReportFocusFences(stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)));
|
||||
return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown));
|
||||
}
|
||||
|
||||
@@ -147,18 +147,6 @@ export function reportListTimestamp(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Display name for the signed-in reader. Birth fields stay off this response. */
|
||||
export function readOwnedSubjectDisplayName(document: unknown): string | undefined {
|
||||
if (!document || typeof document !== "object") return undefined;
|
||||
const subject = (document as { subject?: unknown }).subject;
|
||||
if (!subject || typeof subject !== "object") return undefined;
|
||||
const name = (subject as { displayName?: unknown }).displayName;
|
||||
if (typeof name !== "string") return undefined;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function reportView(
|
||||
row: PersonalReportRecord,
|
||||
job?: PersonalReportJobRecord | null,
|
||||
@@ -765,7 +753,6 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
: undefined);
|
||||
if (typeof markdown === "string" && markdown.trim()) {
|
||||
const validated = deps.validateReadyDocument(row.reportDocument);
|
||||
const subjectDisplayName = readOwnedSubjectDisplayName(validated.ok ? validated.document : undefined);
|
||||
// A failed English read never fails the Chinese report; it reads as unavailable.
|
||||
const english: EnglishEditionRead = deps.loadLongformEnglish
|
||||
? await deps.loadLongformEnglish({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
|
||||
@@ -780,7 +767,6 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
|
||||
factTablesEn: english.factTables,
|
||||
} : {}),
|
||||
...(subjectDisplayName ? { subjectDisplayName } : {}),
|
||||
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
|
||||
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
|
||||
? { calculationCharts: validated.document.charts } : {}),
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ReportDocumentV2 } from "./personal-report-contract";
|
||||
import { reportChartExportSvg } from "./report-chart-export";
|
||||
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
|
||||
import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
|
||||
import { renderReportFocusFences } from "./report-focus-block";
|
||||
import type { ReportFactTable } from "./report-fact-table-schema";
|
||||
import { factCellText, factStatusLabel } from "./report-fact-table-display";
|
||||
import type { ReportLanguage } from "./report-language";
|
||||
@@ -68,10 +67,9 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
|
||||
}
|
||||
// sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's
|
||||
// leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose.
|
||||
const publishBody = (text: string) => renderReportFocusFences(stripReportChartBlocks(text), language);
|
||||
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", publishBody(outline.sourcePreamble));
|
||||
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", stripReportChartBlocks(outline.sourcePreamble));
|
||||
for (const section of outline.sections) {
|
||||
add(`body:${section.id}`, "body", section.title, publishBody(section.sourceMarkdown));
|
||||
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
|
||||
}
|
||||
const chartIds = fencedReportChartIds(projected);
|
||||
for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ReportLanguage } from "./report-language";
|
||||
|
||||
const PLANET_ZH: Record<string, string> = {
|
||||
Sun: "太阳",
|
||||
Moon: "月亮",
|
||||
Mars: "火星",
|
||||
Mercury: "水星",
|
||||
Jupiter: "木星",
|
||||
Venus: "金星",
|
||||
Saturn: "土星",
|
||||
Rahu: "北交点",
|
||||
Ketu: "南交点",
|
||||
};
|
||||
|
||||
const LEVEL_LABEL = {
|
||||
md: { zh: "主运", en: "Major" },
|
||||
ad: { zh: "子运", en: "Sub" },
|
||||
pd: { zh: "次子运", en: "Sub-sub" },
|
||||
} as const;
|
||||
|
||||
const DOMAIN_LABEL = {
|
||||
career: { zh: "事业", en: "Career" },
|
||||
wealth: { zh: "财富", en: "Wealth" },
|
||||
relationship: { zh: "婚恋", en: "Relationship" },
|
||||
} as const;
|
||||
|
||||
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable();
|
||||
const lord = z.string().min(1).max(40).nullable();
|
||||
|
||||
export const reportFocusBlockSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
periods: z.tuple([
|
||||
z.strictObject({ level: z.literal("md"), lord, start: day, end: day }),
|
||||
z.strictObject({ level: z.literal("ad"), lord, start: day, end: day }),
|
||||
z.strictObject({ level: z.literal("pd"), lord, start: day, end: day }),
|
||||
]),
|
||||
domains: z.tuple([
|
||||
z.strictObject({
|
||||
key: z.literal("career"),
|
||||
house: z.literal(10),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
z.strictObject({
|
||||
key: z.literal("wealth"),
|
||||
house: z.literal(2),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
z.strictObject({
|
||||
key: z.literal("relationship"),
|
||||
house: z.literal(7),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
]),
|
||||
caveat: z.string().min(1).max(240),
|
||||
});
|
||||
|
||||
export type ReportFocusBlock = z.infer<typeof reportFocusBlockSchema>;
|
||||
|
||||
export function parseReportFocusBlock(source: string): ReportFocusBlock | null {
|
||||
try {
|
||||
return reportFocusBlockSchema.parse(JSON.parse(source));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function planetLabel(name: string | null, language: ReportLanguage): string {
|
||||
if (!name) return language === "en" ? "not listed" : "未列出";
|
||||
if (language === "zh") return PLANET_ZH[name] ?? name;
|
||||
return name;
|
||||
}
|
||||
|
||||
function monthLabel(iso: string | null): string | null {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
|
||||
}
|
||||
|
||||
export function periodRange(start: string | null, end: string | null): string | null {
|
||||
const from = monthLabel(start);
|
||||
const to = monthLabel(end);
|
||||
if (from && to) return `${from} – ${to}`;
|
||||
return from ?? to;
|
||||
}
|
||||
|
||||
export function focusBlockText(block: ReportFocusBlock, language: ReportLanguage = "zh"): string {
|
||||
const kicker = language === "en" ? "Current period" : "现在走的大运";
|
||||
const periods = block.periods.map((period) => {
|
||||
const label = LEVEL_LABEL[period.level][language];
|
||||
const range = periodRange(period.start, period.end);
|
||||
const name = planetLabel(period.lord, language);
|
||||
return range ? `${label} ${name} ${range}` : `${label} ${name}`;
|
||||
});
|
||||
const domains = block.domains.map((domain) => {
|
||||
const label = DOMAIN_LABEL[domain.key][language];
|
||||
const name = planetLabel(domain.lord, language);
|
||||
const placed = domain.placed === null
|
||||
? (language === "en" ? "house not listed" : "落宫未列出")
|
||||
: (language === "en" ? `house ${domain.placed}` : `第${domain.placed}宫`);
|
||||
const where = language === "en"
|
||||
? `${label}: House ${domain.house} lord · ${name} · ${placed}`
|
||||
: domain.placed === null
|
||||
? `${label}:第${domain.house}宫主 · ${name} · ${placed}`
|
||||
: `${label}:第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
|
||||
return `${where}(${domain.dignity};${domain.role})`;
|
||||
});
|
||||
return [kicker, ...periods, ...domains, block.caveat].join("\n");
|
||||
}
|
||||
|
||||
const FOCUS_FENCE_RE = /```jyotish-focus\r?\n([\s\S]*?)```/g;
|
||||
|
||||
/** Turn the fence into readable lines. A bad fence becomes the same invalid sentence as the page. */
|
||||
export function renderReportFocusFences(markdown: string, language: ReportLanguage = "zh"): string {
|
||||
return markdown.replace(FOCUS_FENCE_RE, (_full, source: string) => {
|
||||
const block = parseReportFocusBlock(String(source).trim());
|
||||
if (!block) return language === "en" ? "Focus data is invalid\n" : "重点数据无效\n";
|
||||
return `${focusBlockText(block, language)}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
export function reportTitleByline(input: {
|
||||
title: string;
|
||||
subjectName?: string | null;
|
||||
createdAt?: string | null;
|
||||
language?: ReportLanguage;
|
||||
}): string | null {
|
||||
const language = input.language ?? "zh";
|
||||
const name = (input.subjectName ?? "").trim();
|
||||
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec((input.createdAt ?? "").trim());
|
||||
const date = dateMatch && validDay(dateMatch[1]) ? dateMatch[1] : "";
|
||||
const showName = name.length > 0 && !input.title.includes(name);
|
||||
if (!showName && !date) return null;
|
||||
if (language === "en") {
|
||||
if (showName && date) return `${name} · Generated ${date}`;
|
||||
if (showName) return name;
|
||||
return `Generated ${date}`;
|
||||
}
|
||||
if (showName && date) return `${name} · 生成于 ${date}`;
|
||||
if (showName) return name;
|
||||
return `生成于 ${date}`;
|
||||
}
|
||||
|
||||
function validDay(iso: string): boolean {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
if (!year || !month || !day) return false;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -417,7 +417,7 @@ function analyzeOrdinaryReportMarkdown(markdown: string, scope: ProjectionScope
|
||||
}
|
||||
|
||||
if (block.type === "fence") {
|
||||
if ((block.lang === "jyotish-chart" || block.lang === "jyotish-focus") && fenceIsPublic(block.raw)) {
|
||||
if (block.lang === "jyotish-chart" && fenceIsPublic(block.raw)) {
|
||||
kept.push(block.raw);
|
||||
continue;
|
||||
}
|
||||
@@ -497,7 +497,7 @@ function projectProseLine(line: string, scope: ProjectionScope = "report"): { ke
|
||||
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
|
||||
export function sanitizeRawAppendixMarkdown(markdown: string): string {
|
||||
return parseBlocks(markdown).map((block) => {
|
||||
if (block.type === "fence" && (block.lang === "jyotish-chart" || block.lang === "jyotish-focus")) {
|
||||
if (block.type === "fence" && block.lang === "jyotish-chart") {
|
||||
return fenceIsPublic(block.raw) ? block.raw : "";
|
||||
}
|
||||
return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => (
|
||||
|
||||
@@ -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: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"fixtureProvenance": {
|
||||
"fictional": true,
|
||||
"edition": "full_data",
|
||||
"command": "From tests.test_report_english_edition CASES day, edition full_data, both languages. Fictional chart only."
|
||||
},
|
||||
"zh": "## 重点\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"入庙\",\"role\":\"功能吉星\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"入友\",\"role\":\"功能吉星\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"中性\",\"role\":\"功能中性\"}],\"caveat\":\"这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。\"}\n```",
|
||||
"en": "## Focus\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"Own Sign\",\"role\":\"functional benefic\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"Friendly Sign\",\"role\":\"functional benefic\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"Neutral\",\"role\":\"functional neutral\"}],\"caveat\":\"This period may bring those house-lord topics forward. It is not a certain event.\"}\n```"
|
||||
}
|
||||
@@ -1165,33 +1165,6 @@ test("core read: returns a legitimate ready document after canonical re-validati
|
||||
assert.ok(response.body.reportDocument);
|
||||
});
|
||||
|
||||
test("core read: owner response includes the display name and not the birth fingerprint", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const row = {
|
||||
...seedRecord(),
|
||||
reportDocument: {
|
||||
schemaVersion: "report_document.v2",
|
||||
subject: { displayName: "虚构甲", birthFingerprint: "ab".repeat(16) },
|
||||
executiveSummary: { headline: "摘要", summary: "正文" },
|
||||
} as unknown as PersonalReportRecord["reportDocument"],
|
||||
};
|
||||
persistence.rows.set(REPORT_ID, row);
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
loadLongformMarkdown: async () => "# 长报告\n\n## 重点\n正文",
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.subjectDisplayName, "虚构甲");
|
||||
assert.equal(JSON.stringify(response.body).includes("birthFingerprint"), false);
|
||||
assert.equal(JSON.stringify(response.body.reportDocument).includes("虚构甲"), false);
|
||||
});
|
||||
|
||||
test("core read: markdown loader returns longform and hides the five-chapter body when it is missing", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
persistence.rows.set(REPORT_ID, seedRecord());
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
|
||||
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
||||
import { buildReportExportBlocks, joinExportBlocks } from "../src/lib/report-export-blocks.ts";
|
||||
import { parseReportFocusBlock } from "../src/lib/report-focus-block.ts";
|
||||
import { projectOrdinaryReportDocument, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-focus-fictional-day.json", import.meta.url), "utf8")) as {
|
||||
fixtureProvenance: { fictional: boolean; command: string; edition: string };
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
|
||||
const OLD_MARKDOWN = `# 印度占星星盘报告
|
||||
|
||||
重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。
|
||||
|
||||
## 重点
|
||||
|
||||
- 事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。
|
||||
|
||||
## 出生资料与图盘
|
||||
|
||||
旧正文仍在。
|
||||
`;
|
||||
|
||||
function markup(markdown: string, props: { subjectName?: string; createdAt?: string; language?: "zh" | "en" } = {}) {
|
||||
return renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown, drawnThrough: 0, ...props }));
|
||||
}
|
||||
|
||||
test("fictional day-chart golden renders the focus card without MD labels", () => {
|
||||
assert.equal(fixture.fixtureProvenance.fictional, true);
|
||||
assert.match(fixture.fixtureProvenance.command, /full_data/);
|
||||
assert.equal(fixture.fixtureProvenance.edition, "full_data");
|
||||
const html = markup(fixture.zh);
|
||||
assert.match(html, /现在走的大运/);
|
||||
assert.match(html, /主运/);
|
||||
assert.match(html, /子运/);
|
||||
assert.match(html, /次子运/);
|
||||
assert.match(html, /事业/);
|
||||
assert.match(html, /财富/);
|
||||
assert.match(html, /婚恋/);
|
||||
assert.match(html, /这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。/);
|
||||
assert.doesNotMatch(html, /\bMD\b|\bAD\b|\bPD\b/);
|
||||
assert.doesNotMatch(html, /\{"version"/);
|
||||
const english = markup(fixture.en, { language: "en" });
|
||||
assert.match(english, /Current period/);
|
||||
assert.match(english, /Career/);
|
||||
assert.match(english, /This period may bring those house-lord topics forward/);
|
||||
assert.equal(parseReportFocusBlock("{"), null);
|
||||
assert.match(markup("## 重点\n\n```jyotish-focus\n{not-json}\n```\n"), /重点数据无效/);
|
||||
assert.match(markup("## Focus\n\n```jyotish-focus\n{not-json}\n```\n", { language: "en" }), /Focus data is invalid/);
|
||||
});
|
||||
|
||||
test("export writes the focus as words and the public projection keeps the fence", () => {
|
||||
const blocks = buildReportExportBlocks({ markdown: fixture.zh });
|
||||
const text = joinExportBlocks(blocks);
|
||||
assert.doesNotMatch(text, /\{"version"/);
|
||||
assert.match(text, /现在走的大运/);
|
||||
assert.match(text, /这段时间可能突出这些宫主所守护的主题/);
|
||||
const projected = projectOrdinaryReportMarkdown(fixture.zh);
|
||||
assert.match(projected, /```jyotish-focus/);
|
||||
const document = projectOrdinaryReportDocument({
|
||||
schemaVersion: "report_document.v2",
|
||||
subject: { displayName: "虚构甲" },
|
||||
executiveSummary: { headline: "摘要", summary: "正文" },
|
||||
});
|
||||
assert.equal(JSON.stringify(document).includes("虚构甲"), false);
|
||||
});
|
||||
|
||||
test("old prose without a fence is unchanged", () => {
|
||||
const html = markup(OLD_MARKDOWN);
|
||||
assert.match(html, /重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。/);
|
||||
assert.match(html, /事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。/);
|
||||
assert.doesNotMatch(html, /重点数据无效|personal-report-focus-card|现在走的大运/);
|
||||
});
|
||||
|
||||
test("title byline covers a name, no name, and a title that already includes the name", () => {
|
||||
const createdAt = "2026-10-13T08:00:00.000Z";
|
||||
const named = markup("# 印度占星星盘报告\n", { subjectName: "虚构甲", createdAt });
|
||||
assert.match(named, /虚构甲 · 生成于 2026-10-13/);
|
||||
const unnamed = markup("# 印度占星星盘报告\n", { createdAt });
|
||||
assert.match(unnamed, /生成于 2026-10-13/);
|
||||
assert.doesNotMatch(unnamed, /虚构甲/);
|
||||
const already = markup("# 虚构甲的印度占星星盘报告\n", { subjectName: "虚构甲", createdAt });
|
||||
assert.match(already, /生成于 2026-10-13/);
|
||||
assert.doesNotMatch(already, /虚构甲 · 生成于/);
|
||||
const english = markup("# Vedic Astrology Chart Report\n", { subjectName: "Fictional", createdAt, language: "en" });
|
||||
assert.match(english, /Fictional · Generated 2026-10-13/);
|
||||
});
|
||||
|
||||
test("the signed-in envelope carries the display name onto the reader", () => {
|
||||
const ready = classifyReportEnvelope(200, {
|
||||
report: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
status: "ready",
|
||||
failureCode: null,
|
||||
createdAt: "2026-10-13T00:00:00Z",
|
||||
},
|
||||
longformMarkdown: "# 印度占星星盘报告\n",
|
||||
subjectDisplayName: "虚构甲",
|
||||
});
|
||||
assert.equal(ready.phase, "markdown-ready");
|
||||
if (ready.phase === "markdown-ready") assert.equal(ready.subjectDisplayName, "虚构甲");
|
||||
});
|
||||
@@ -91,12 +91,7 @@ def _load_bhava_rows():
|
||||
return bhava_house_rows
|
||||
|
||||
|
||||
def _sanitize_pl9_ai_density_markdown(
|
||||
markdown: str,
|
||||
title: str = "印度占星星盘报告",
|
||||
*,
|
||||
opening_summary: bool = True,
|
||||
) -> str:
|
||||
def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星星盘报告") -> str:
|
||||
"""Keep the parity volume's facts while removing internal-only labels."""
|
||||
|
||||
try:
|
||||
@@ -116,8 +111,6 @@ def _sanitize_pl9_ai_density_markdown(
|
||||
from pl9_language_terms import translate_zh_glued_phrases
|
||||
except ModuleNotFoundError: # pragma: no cover - package import compatibility
|
||||
from scripts.pl9_language_terms import translate_zh_glued_phrases
|
||||
hold_focus_fences, restore_focus_fences = _focus_fence_tools()
|
||||
markdown, focus_slots = hold_focus_fences(markdown)
|
||||
# Whole phrases and glued planet+dignity tokens before shorter word swaps.
|
||||
# Chapters appended after the parity strip still pass through here.
|
||||
text = translate_zh_glued_phrases(_sanitize_pl9_public_markdown(markdown))
|
||||
@@ -522,12 +515,10 @@ def _sanitize_pl9_ai_density_markdown(
|
||||
continue
|
||||
first_body_line = line
|
||||
break
|
||||
has_opening_summary = _opening_focus_authored(text) or (
|
||||
bool(first_body_line) and any(
|
||||
marker in first_body_line for marker in ("摘要:", "结论:", "重点:")
|
||||
)
|
||||
has_opening_summary = bool(first_body_line) and any(
|
||||
marker in first_body_line for marker in ("摘要:", "结论:", "重点:")
|
||||
)
|
||||
if opening_summary and text.startswith(f"# {title}") and not has_opening_summary:
|
||||
if text.startswith(f"# {title}") and not has_opening_summary:
|
||||
summary = "\n".join(
|
||||
[
|
||||
f"# {title}",
|
||||
@@ -541,7 +532,7 @@ def _sanitize_pl9_ai_density_markdown(
|
||||
]
|
||||
)
|
||||
text = re.sub(r"^#\s+.*$", summary, text, count=1, flags=re.MULTILINE)
|
||||
return restore_focus_fences(strip_han_plural_s(text) + "\n", focus_slots)
|
||||
return strip_han_plural_s(text) + "\n"
|
||||
|
||||
|
||||
|
||||
@@ -896,8 +887,7 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
|
||||
except ModuleNotFoundError: # pragma: no cover - package import compatibility
|
||||
from scripts.pl9_language_terms import normalize_en_markdown_terms
|
||||
|
||||
hold_focus_fences, restore_focus_fences = _focus_fence_tools()
|
||||
text, focus_slots = hold_focus_fences(markdown)
|
||||
text = markdown
|
||||
replacements = {
|
||||
"# PL9 对标主册": f"# {title}",
|
||||
"PL9 对标主册": title,
|
||||
@@ -1276,7 +1266,7 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
|
||||
# 防御性幂等:折叠残留的 "condition condition"(确保 sanitize 可安全重复执行)。
|
||||
text = re.sub(r"\b(condition)(?:\s+condition)+\b", r"\1", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
|
||||
return restore_focus_fences(text, focus_slots)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -1289,10 +1279,7 @@ _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|jyotish-focus)\n.*?\n```",
|
||||
re.DOTALL,
|
||||
)
|
||||
_ZH_CHART_FENCE_RE = re.compile(r"```jyotish-chart\n.*?\n```", re.DOTALL)
|
||||
|
||||
|
||||
def _zh_body_residue(markdown: str) -> dict[str, int]:
|
||||
@@ -2243,36 +2230,14 @@ def _append_native_rashi_dashas(markdown: str, packet: dict) -> str:
|
||||
return markdown.rstrip() + "\n\n" + chapter
|
||||
|
||||
|
||||
def _focus_fence_tools():
|
||||
try:
|
||||
from report_focus_block import hold_focus_fences, restore_focus_fences
|
||||
except ModuleNotFoundError: # pragma: no cover - package import compatibility
|
||||
from scripts.report_focus_block import hold_focus_fences, restore_focus_fences
|
||||
return hold_focus_fences, restore_focus_fences
|
||||
|
||||
|
||||
def _opening_focus_authored(text: str) -> bool:
|
||||
"""True when the title is already followed by the focus heading or fence.
|
||||
|
||||
The fixed checklist must not come back just because the old one-line
|
||||
「重点:」 summary was removed.
|
||||
"""
|
||||
|
||||
head = "\n".join((text or "").splitlines()[:16])
|
||||
if re.search(r"(?m)^## (?:重点|Focus)\s*$", head):
|
||||
return True
|
||||
return "```jyotish-focus" in head or "JYOTISHFOCUSHOLD" in head
|
||||
|
||||
|
||||
def _insert_opening_focus(markdown: str, focus: str) -> str:
|
||||
"""Put the current-period focus directly under the title."""
|
||||
if "```jyotish-focus" not in (focus or ""):
|
||||
first = next((line.strip() for line in focus.splitlines() if line.strip()), "")
|
||||
if not first or first in markdown:
|
||||
return markdown
|
||||
if "```jyotish-focus" in (markdown or ""):
|
||||
return markdown
|
||||
match = re.search(r"^#\s+.+$", markdown or "", flags=re.MULTILINE)
|
||||
match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE)
|
||||
if not match:
|
||||
return focus.rstrip() + "\n\n" + (markdown or "")
|
||||
return focus.rstrip() + "\n\n" + markdown
|
||||
return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :]
|
||||
|
||||
|
||||
@@ -2321,7 +2286,6 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
zh_structured_markdown = _sanitize_pl9_ai_density_markdown(
|
||||
_render_pl9_ai_density_raw_data_markdown_en(packet),
|
||||
_pl9_public_report_title(packet),
|
||||
opening_summary=False,
|
||||
)
|
||||
zh_structured_appendix = re.sub(
|
||||
r"^#\s+.*?(?:\n{2,}|\n)",
|
||||
|
||||
@@ -1393,92 +1393,61 @@ def missing_field_count(packet: dict) -> int:
|
||||
return len(fields)
|
||||
|
||||
|
||||
def _iso_day(value: Any) -> str | None:
|
||||
text = _date_text(value)
|
||||
return text if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text) else None
|
||||
|
||||
|
||||
def _current_period_rows(packet: dict) -> dict[str, dict]:
|
||||
"""Current MD/AD/PD rows already stored on the dasha timeline. No new dates."""
|
||||
|
||||
dasha = _dict(_timing(packet).get("dasha"))
|
||||
current = _dict(dasha.get("current_dasha"))
|
||||
md_row = current if current.get("lord") else {}
|
||||
if not md_row:
|
||||
for period in _list(dasha.get("timeline")):
|
||||
if isinstance(period, dict) and period.get("is_current"):
|
||||
md_row = period
|
||||
break
|
||||
ad_row: dict = {}
|
||||
pd_row: dict = {}
|
||||
for row in _list(md_row.get("antardasha_timeline")):
|
||||
if isinstance(row, dict) and row.get("is_current"):
|
||||
ad_row = row
|
||||
for child in _list(row.get("pratyantar_dasha_timeline")):
|
||||
if isinstance(child, dict) and child.get("is_current"):
|
||||
pd_row = child
|
||||
break
|
||||
break
|
||||
return {"md": md_row, "ad": ad_row, "pd": pd_row}
|
||||
|
||||
|
||||
def _focus_fence(payload: dict) -> str:
|
||||
try:
|
||||
from report_focus_block import build_focus_fence
|
||||
except ModuleNotFoundError: # pragma: no cover - package import compatibility
|
||||
from scripts.report_focus_block import build_focus_fence
|
||||
return build_focus_fence(payload)
|
||||
|
||||
|
||||
def opening_focus_block(packet: dict) -> str:
|
||||
"""Current MD/AD/PD plus career, wealth, and relationship house lords.
|
||||
|
||||
The reader draws the card from the fence. This function does not compute
|
||||
a new dasha or house lord; missing dates stay null.
|
||||
"""
|
||||
"""Current MD/AD/PD plus career, wealth, and relationship house lords."""
|
||||
|
||||
language = _lang(packet)
|
||||
lords = _current_lords(packet)
|
||||
houses = _houses(packet)
|
||||
planets = _planets(packet)
|
||||
classification = _functional_classification(packet)
|
||||
rows = _current_period_rows(packet)
|
||||
periods = []
|
||||
for level in ("md", "ad", "pd"):
|
||||
row = rows[level]
|
||||
lord = str(row.get("lord") or "").strip()
|
||||
periods.append({
|
||||
"level": level,
|
||||
"lord": lord or None,
|
||||
"start": _iso_day(row.get("start")),
|
||||
"end": _iso_day(row.get("end")),
|
||||
})
|
||||
domains = []
|
||||
for key, number in (("career", 10), ("wealth", 2), ("relationship", 7)):
|
||||
|
||||
def lord_line(number: int, topic_zh: str, topic_en: str) -> str:
|
||||
house = _house_row(houses, number)
|
||||
lord = str(house.get("lord") or "").strip()
|
||||
lord = str(house.get("lord") or "")
|
||||
row = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
|
||||
placed = row.get("house")
|
||||
domains.append({
|
||||
"key": key,
|
||||
"house": number,
|
||||
"lord": lord or None,
|
||||
"placed": placed if isinstance(placed, int) else None,
|
||||
"dignity": _dignity(row.get("status") or row.get("dignity"), language),
|
||||
"role": _role(classification.get(lord), language),
|
||||
})
|
||||
dignity = _dignity(row.get("status") or row.get("dignity"), language)
|
||||
role = _role(classification.get(lord), language)
|
||||
if language == "zh":
|
||||
return (
|
||||
f"- {topic_zh}第{number}宫主 {_planet(lord, language)} 落在第{placed if isinstance(placed, int) else '未列出'}宫,"
|
||||
f"尊贵为{dignity},功能吉凶为{role}。"
|
||||
)
|
||||
return (
|
||||
f"- {topic_en} house {number} lord {lord or 'not listed'} is in house "
|
||||
f"{placed if isinstance(placed, int) else 'not listed'}, dignity {dignity}, functional nature {role}."
|
||||
)
|
||||
|
||||
if language == "zh":
|
||||
caveat = "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"
|
||||
heading = "## 重点"
|
||||
lines = [
|
||||
"重点:当前主运 MD {md},子运 AD {ad},次子运 PD {pd}。".format(
|
||||
md=_planet(lords["md"], language),
|
||||
ad=_planet(lords["ad"], language),
|
||||
pd=_planet(lords["pd"], language),
|
||||
),
|
||||
"",
|
||||
"## 重点",
|
||||
lord_line(10, "事业", "Career"),
|
||||
lord_line(2, "财富", "Resources"),
|
||||
lord_line(7, "婚恋", "Relationship"),
|
||||
"- 这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。",
|
||||
]
|
||||
else:
|
||||
caveat = "This period may bring those house-lord topics forward. It is not a certain event."
|
||||
heading = "## Focus"
|
||||
fence = _focus_fence({
|
||||
"version": 1,
|
||||
"periods": periods,
|
||||
"domains": domains,
|
||||
"caveat": caveat,
|
||||
})
|
||||
return f"{heading}\n\n{fence}"
|
||||
lines = [
|
||||
"Focus: current Maha Dasha MD {md}, Antar Dasha AD {ad}, Pratyantar Dasha PD {pd}.".format(
|
||||
md=lords["md"] or "not listed",
|
||||
ad=lords["ad"] or "not listed",
|
||||
pd=lords["pd"] or "not listed",
|
||||
),
|
||||
"",
|
||||
"## Focus",
|
||||
lord_line(10, "事业", "Career"),
|
||||
lord_line(2, "财富", "Resources"),
|
||||
lord_line(7, "婚恋", "Relationship"),
|
||||
"- This period may bring those house-lord topics forward. It is not a certain event.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _strength_snapshot(packet: dict) -> dict:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structured opening-focus fence for the full-data report.
|
||||
|
||||
The engine writes one ``jyotish-focus`` JSON fence. The report page renders
|
||||
that fence; it does not recompute dasha lords or house lords.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
FOCUS_FENCE_RE = re.compile(r"```jyotish-focus\n(.*?)\n```", re.DOTALL)
|
||||
_FOCUS_BLOCK_RE = re.compile(r"```jyotish-focus\n.*?\n```", re.DOTALL)
|
||||
_HOLD_TOKEN = "JYOTISHFOCUSHOLD{index}END"
|
||||
|
||||
|
||||
def build_focus_fence(payload: dict[str, Any]) -> str:
|
||||
"""Return a one-line JSON fence. Values are already public words."""
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
return f"```jyotish-focus\n{body}\n```"
|
||||
|
||||
|
||||
def parse_focus_fence(markdown: str) -> dict[str, Any] | None:
|
||||
match = FOCUS_FENCE_RE.search(markdown or "")
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def hold_focus_fences(markdown: str) -> tuple[str, list[str]]:
|
||||
"""Lift fences out of the glossary pass so planet words stay valid JSON."""
|
||||
|
||||
slots: list[str] = []
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
slots.append(match.group(0))
|
||||
return _HOLD_TOKEN.format(index=len(slots) - 1)
|
||||
|
||||
return _FOCUS_BLOCK_RE.sub(repl, markdown or ""), slots
|
||||
|
||||
|
||||
def restore_focus_fences(markdown: str, slots: list[str]) -> str:
|
||||
text = markdown
|
||||
for index, slot in enumerate(slots):
|
||||
text = text.replace(_HOLD_TOKEN.format(index=index), slot)
|
||||
return text
|
||||
@@ -8,18 +8,15 @@ calculate a new dasha or import PyJHora.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
|
||||
from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown
|
||||
from scripts.pl9_interpretation_packs import (
|
||||
_current_lords,
|
||||
attach_full_data_interpretation,
|
||||
missing_field_count,
|
||||
opening_focus_block,
|
||||
section_coverage,
|
||||
)
|
||||
from scripts.report_focus_block import FOCUS_FENCE_RE, parse_focus_fence
|
||||
from scripts.pl9_reader_english import HAN
|
||||
|
||||
FORBIDDEN_ZH = (
|
||||
@@ -173,15 +170,9 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None:
|
||||
packet = _packet("zh")
|
||||
attach_full_data_interpretation(packet)
|
||||
focus = opening_focus_block(packet)
|
||||
# 原值:focus.startswith("重点:"),并且正文含 MD / AD / PD。
|
||||
# 新值:开篇是「## 重点」加一个 jyotish-focus 围栏,不再写那一行行话。
|
||||
# 原因:产品选择把开篇做成摘要卡,围栏交给阅读页画,不在正文里堆 MD/AD/PD。
|
||||
assert focus.startswith("## 重点")
|
||||
assert "重点:当前主运" not in focus
|
||||
assert "MD " not in focus and "AD " not in focus and "PD " not in focus
|
||||
payload = parse_focus_fence(focus)
|
||||
assert payload is not None
|
||||
assert [row["house"] for row in payload["domains"]] == [10, 2, 7]
|
||||
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
|
||||
@@ -203,12 +194,7 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None:
|
||||
def test_english_focus_and_new_sections_have_no_han() -> None:
|
||||
packet = _packet("en")
|
||||
text = render_full_data_markdown(packet)
|
||||
# 原值:正文含 "Focus: current Maha Dasha MD"。
|
||||
# 新值:英文开篇是「## Focus」加 jyotish-focus 围栏,不再写那一行。
|
||||
# 原因:与中文摘要卡同一结构;大运章里的 AD/PD 表头仍保留。
|
||||
assert "## Focus" in text
|
||||
assert "```jyotish-focus" in text
|
||||
assert "Focus: current Maha Dasha MD" not in text
|
||||
assert "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
|
||||
@@ -223,60 +209,6 @@ def test_english_focus_and_new_sections_have_no_han() -> None:
|
||||
assert leaked == [], leaked[:12]
|
||||
|
||||
|
||||
def _focus_dates(packet: dict, level: str) -> tuple[str | None, str | None]:
|
||||
dasha = packet["worksheets"]["timing_and_predictive_systems"]["dasha"]
|
||||
current = dasha["current_dasha"]
|
||||
if level == "md":
|
||||
return current.get("start"), current.get("end")
|
||||
for row in current["antardasha_timeline"]:
|
||||
if not row.get("is_current"):
|
||||
continue
|
||||
if level == "ad":
|
||||
return row.get("start"), row.get("end")
|
||||
for child in row["pratyantar_dasha_timeline"]:
|
||||
if child.get("is_current"):
|
||||
return child.get("start"), child.get("end")
|
||||
return None, None
|
||||
|
||||
|
||||
def test_opening_focus_fence_matches_current_lords_and_skips_the_fixed_list() -> None:
|
||||
for language in ("zh", "en"):
|
||||
packet = _packet(language)
|
||||
lords = _current_lords(packet)
|
||||
text = render_full_data_markdown(packet)
|
||||
fences = FOCUS_FENCE_RE.findall(text.split("## 结构化资料附录", 1)[0].split("## Structured Data Appendix", 1)[0])
|
||||
assert len(fences) == 1, language
|
||||
payload = json.loads(fences[0])
|
||||
assert payload["version"] == 1
|
||||
assert [row["level"] for row in payload["periods"]] == ["md", "ad", "pd"]
|
||||
assert [row["lord"] for row in payload["periods"]] == [lords["md"], lords["ad"], lords["pd"]]
|
||||
for row in payload["periods"]:
|
||||
start, end = _focus_dates(packet, row["level"])
|
||||
assert row["start"] == start
|
||||
assert row["end"] == end
|
||||
assert [row["key"] for row in payload["domains"]] == ["career", "wealth", "relationship"]
|
||||
assert [row["house"] for row in payload["domains"]] == [10, 2, 7]
|
||||
opening = text.split("## 出生资料", 1)[0].split("## Birth Data", 1)[0]
|
||||
assert "重点:当前主运" not in opening
|
||||
assert "先核对出生资料、图盘" not in text
|
||||
if language == "zh":
|
||||
assert "MD " not in opening and "AD " not in opening and "PD " not in opening
|
||||
assert "功能吉凶为功能吉星" not in opening
|
||||
assert payload["caveat"] == "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"
|
||||
else:
|
||||
assert "Focus: current Maha Dasha" not in opening
|
||||
|
||||
|
||||
def test_opening_focus_keeps_null_when_a_period_has_no_date() -> None:
|
||||
packet = _packet("zh")
|
||||
packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["current_dasha"]["antardasha_timeline"][0]["end"] = ""
|
||||
payload = parse_focus_fence(render_full_data_markdown(packet))
|
||||
assert payload is not None
|
||||
ad = next(row for row in payload["periods"] if row["level"] == "ad")
|
||||
assert ad["start"] == "2001-01-01"
|
||||
assert ad["end"] is None
|
||||
|
||||
|
||||
def test_yoga_effect_does_not_assert_lifespan() -> None:
|
||||
# 原值:作用列写「长寿;健康良好」/ Longevity。
|
||||
# 新值:作用列改成体质与恢复力的参考句。名称「长寿格局」和 Ayur Yoga (Strong Longevity) 可以留下。
|
||||
|
||||
Reference in New Issue
Block a user