Merge #218 post-check factual rules and basis audit (BUG-1860)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
This commit is contained in:
@@ -5,6 +5,11 @@
|
||||
- 出生分钟未确认时,个人报告用填报的时间范围核对各分盘上升。误差未知时按前后各 30 分钟。必需分盘在范围内换座的结论暂不能判断。
|
||||
- 沿用当前 Skill 版本(BUG-1864)。
|
||||
|
||||
## 2026-11-12 — 个人报告补充事实核对与依据清单
|
||||
|
||||
- 逐节核对宫主与落宫、标准 SAV 总和、阻碍宫及具体健康风险,并拦截没有依据的数值准确率说法。
|
||||
- 「依据与边界」未通过核对时,页面与 Markdown 显示由事实包生成的依据清单。沿用当前 Skill 版本(BUG-1860)。
|
||||
|
||||
## 2026-11-12 — 个人报告按当前证据合同写作并单列核验状态
|
||||
|
||||
- 解读开头明确废止历史预测数字和旧分级口径,功能吉凶按事实包选中的版本解释。
|
||||
|
||||
@@ -25128,6 +25128,22 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:`codex/reading-runtime-sources-20261112`,待发布。
|
||||
|
||||
## BUG-1860 | 个人报告事实复查漏拦错误结论,依据节退回后缺少说明
|
||||
|
||||
- 状态:fixed-pending-verify(合成回归通过,指定样稿待回放)。
|
||||
- 验收退回:分盘宫主、合法状态说明误拦。
|
||||
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
|
||||
- 影响面:中文个人报告的逐节复查、已保存的依据与边界、Markdown 导出。
|
||||
- 用户现象:报告可能混用宫主与落宫、把标准 SAV 总和当成个人强弱、混写阻碍宫与宫主,或写入具体健康风险和数值准确率。合法的技法状态与预测边界说明可能被旧禁词误拦。依据节被拦时只剩占位说明。
|
||||
- 触发条件:模型草稿含上述表达并进入 `acceptReportReadingDraft`。
|
||||
- 根因:逐节复查未用事实包核对这些说法。「检测器」「死亡」按子串拒绝。页面和导出用同一通用占位,未保存可显示的分级依赖。
|
||||
- 修复:新增五项逐句复查,收窄两项禁词。依据节被拦时只从事实包分级生成审计清单,页面与导出共用。
|
||||
- 验证:合成用例覆盖触发与合法句式、新旧阻碍宫形状、依据节和其他节的渲染区别。已知验证缺口见 `docs/tasks/PROGRESS-reading-postcheck-facts-20261112.md`。
|
||||
- 防复发:`report-reading-postcheck-facts-20261112.test.ts` 锁定逐节拒绝原因、存储回读与页面和 Markdown 的一致输出。
|
||||
- 相关记录:BUG-1854、BUG-1859、BUG-1848、#192。
|
||||
- 复发自:无。
|
||||
- 修复版本:`codex/reading-postcheck-facts-20261112`,待发布。
|
||||
|
||||
## BUG-1864 | 个人报告未确认出生分钟时漏查分盘边界
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# #218 验收退回:分盘宫主误拦、合法状态说明误拦(2026-11-13,Claude)
|
||||
|
||||
本文件补充 `TASK-reading-postcheck-facts-20261112.md`,两者冲突时以本文件为准。
|
||||
|
||||
验收对象是 `ad277359`。其余部分已核对通过,不要改动:5 个新增退回原因、依据清单、页面与 MD 导出、健康词表。
|
||||
|
||||
## 原样稿回放结果(验收主机,脱敏)
|
||||
|
||||
用上一版验收包里三份原始稿和当时的事实包回放:
|
||||
|
||||
| 稿 | 结果 |
|
||||
|---|---|
|
||||
| A-1 | 健康节被拦:current_advice_in_replay。建议节被拦:double_transit_scope。依据节被拦:banned_phrase(误拦,见问题 2)。依据清单 8 行正常生成 |
|
||||
| A-2 | 财富节被拦:house_lord_mismatch(应拦)。健康节被拦:badhaka_role_mix(应拦)。建议节被拦:current_advice_in_replay(应拦)。**事业节、感情节被拦:house_lord_mismatch(误拦,见问题 1)** |
|
||||
| B-1 | 健康节被拦:health_outcome(应拦,「炎症」)。其余各节通过。三份稿里健康词表没有其他误伤 |
|
||||
|
||||
## 问题 1:分盘里的宫主被当作本命宫主核对
|
||||
|
||||
误拦的两句:
|
||||
|
||||
- 「D10 上升金牛,其第十宫主土星在 D10 第三宫」:在 D10 里,第十宫是水瓶,宫主是土星,这句是对的。
|
||||
- 「D9 婚姻分盘里……第七宫主月亮落在天蝎」:在 D9 里,第七宫是巨蟹,宫主是月亮,这句也是对的。
|
||||
|
||||
`houseLordMismatch` 一律用本命 `planets.<X>.owns_houses` 核对,所以把这两句误判了。
|
||||
|
||||
**改法:**
|
||||
|
||||
- 同一分句里出现分盘标记时,不按本命核对。分盘标记包括:`D` 加数字(D2–D60)、「分盘」、「九分」、「事业盘」、「财富盘」、「健康盘」、「障碍盘」。
|
||||
- 如果事实包里有该分盘的上升星座,可以按「从该分盘上升数宫,再取宫主」来核对。没有就跳过。
|
||||
- 跳过的规则写进 PROGRESS。
|
||||
|
||||
## 问题 2:合法状态说明和边界说明被禁用词误拦
|
||||
|
||||
误拦的两句:
|
||||
|
||||
- 「多条 Yoga 按形成状态仍是检测器命中/待核实」:这是合法的状态说明(她 §4)。现行规则只放行「检测器」后面紧跟「检出」或「识别」的写法。
|
||||
- 「不写疾病名、诊断、死亡、离婚……」:这是边界说明。否定词表里没有「不写」。
|
||||
|
||||
**改法:**
|
||||
|
||||
- 「检测器」后面可以紧跟的词,加上「命中」。另外,同一分句里在「检测器」之后出现「待核实」「未确认」「候选」就算满足,中间允许有「/」「、」「,」。
|
||||
- 「死亡」前面的否定词,加上:不写、不谈、不涉及、不判断、不给、不讨论、不推断、避免。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 新增测试(全部用合成文字,按上面四句的结构仿写):
|
||||
- 分盘宫主的两句不触发;
|
||||
- 本命里「水星掌管第五宫与第十宫」仍然触发;
|
||||
- 「检测器命中/待核实」不触发;
|
||||
- 「不写……死亡……」不触发;
|
||||
- 「检测器显示」「有死亡风险」仍然触发。
|
||||
2. 原有测试全部通过。
|
||||
3. 我会在验收主机上再回放一遍。预期结果:A-2 的事业节、感情节通过;A-1 的依据节不再因 banned_phrase 被拦(如果因别的原因被拦,记下原因)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
原任务书的「不要碰」清单继续有效。BLOCKED.md 中 #218 的条目在本轮删除,因为回放已由 Claude 完成。
|
||||
|
||||
BUG-1860:在原条目里补一行「验收退回:分盘宫主、合法状态说明误拦」,只改这一条。
|
||||
@@ -0,0 +1,30 @@
|
||||
# PROGRESS:个人报告事实复查与依据清单(#218)
|
||||
|
||||
## 实施
|
||||
|
||||
- 按 Claude 放行评论 42317 接续 `codex/reading-postcheck-facts-20261112`。任务书提交 `fd1bd758895ba98908c193e1c3bb2ca2884425de` 建于 #207 合入提交 `2f374fff281622f342151a97fa6f6d758de74cc8`。开工时合入 `origin/staging` 的 #216(`718bc991368cd3eb33de60e93c22600e80a139f1`)。
|
||||
- 复查按「。;!?」分句。宫主用 `planets.<行星>.owns_houses`,落宫措辞保留。337 只作校验。阻碍宫读取 `badhaka_sthana.house` 或旧 `house`,保留独立宫主落点。新拒绝原因是 `house_lord_mismatch`、`sav_total_as_strength`、`badhaka_role_mix`、`health_outcome` 和 `accuracy_claim`。已有禁词或前置复查先命中时沿用原原因。
|
||||
- 「检测器」只放行同句同时说明「待核实」「未确认」或「候选」的「检测器检出」「检测器识别」。在「死亡」之前出现约定的否定措辞才放行。
|
||||
- 依据节被拦时在 v3 的该节保存可选审计行,页面和 Markdown 共用格式。缺失或临界依赖按 `report_evidence_grade_v1` 的状态判断。中文名通过已知 id 映射,未知 id 原样显示。历史报告没有审计数据时沿用已保存的占位内容。
|
||||
- 未更改既有断言。新增测试使用合成文字,覆盖规则触发、合法句式、只退回对应节、原有依据节通过、审计保存与两端渲染。
|
||||
|
||||
## 健康词表与样稿
|
||||
|
||||
- 健康和总评检查炎症、感染、外伤、受伤、损伤、骨折、手术、出血、发炎、结石、肝、肾、心脏、心血管、心律、肺、胃、血压、血糖、过敏、失眠、运动风险和运动损伤(由「损伤」命中)。「心理」「心情」「关心」照常通过。合成结构句「第六宫主所在位置已算出」通过。
|
||||
- Claude 已在验收主机对首轮提交回放 A-1、A-2、B-1。A-2 财富、健康与 B-1 健康的预期拦截均生效,依据清单正常生成,健康词表没有其他误伤。A-2 的事业、感情及 A-1 的依据存在误拦,详见修复单。原样稿与事实包保留在验收主机,首轮本机缺包条目已解除。
|
||||
|
||||
## 验证
|
||||
|
||||
- 开工基线上的四份定向测试 38 项通过。合入 #217 后使用 staging `b1bb5c53fd19307caf7e4f2fd519bd7809daffe6` 为最终对照,保留两单 Bug 记录和变更日志。五份定向测试 40 项通过,含 #217 的提示与附录回归。
|
||||
- 合并后 `npm run lint` 为 0 错误、62 条既有警告。生产构建成功,首页 `/` 保持 `○ Static`。与 staging 相同的前端树逐项比较首页 39 个首屏资源,JS gzip 均为 515860 字节,CSS gzip 由 90886 变为 90906 字节(+0.022%)。
|
||||
- TypeScript `--noEmit` 通过。前端全量按 `npm test` 同一文件清单与同一并发数对照。staging 3665 项中 3638 通过,分支 3672 项中 3645 通过,两组均有相同的 27 项失败、0 取消、0 跳过,退出码均为 1。既有测试名完整保留,新增 7 项全部通过。
|
||||
- Windows 快速门统一设置 `PYTHONUTF8=1` 后重跑,1463 项通过、2 项跳过、29 个子测试通过,退出 1。失败的 3 项均为 `tests/test_upstream_technique_layers.py` 的既有基线失败(固定公开盘、大运注册表与 Kaala 对照)。第一次重试的 GBK 子进程解码失败在最终运行中消失。隐私守卫 62 项通过。
|
||||
|
||||
## 验收返工(2026-11-13)
|
||||
|
||||
- 按 Claude 评论 42471 及 `docs/tasks/FIX-reading-postcheck-facts-1.md` 修复,沿用 PR #225。包含 D2–D60 或任务书列出的分盘称呼的分句按修复单跳过宫主核对。D1 与其他分句仍核对本命宫主。
|
||||
- 「检测器」后可跟「命中」。每处检测器措辞需要同一分句内随后出现待核实、未确认或候选,支持斜杠、顿号和逗号。「死亡」按修复单扩充同句前置边界词。合成回归保留原禁词和本命宫主触发断言。
|
||||
- 由 Claude 在验收主机对新提交再回放三份样稿,核对 A-2 的事业、感情及 A-1 依据节的误拦解除情况。
|
||||
- 修复单分支的五份定向测试 42 项通过。合入 #224 后,以 staging `8eb54b18a29e11bbabb66e5967df5746b6a2b78c` 为对照,变更日志和 Bug 历史保留两单记录,合并后的六份定向测试 45 项通过。Lint 0 错误、62 条既有警告。生产构建通过,首页保持静态,首屏 39 个资源,JS gzip 515860 字节,CSS gzip 90906 字节,与 #218 首轮交付相同。
|
||||
- 合入 #224 前的 Python 快速门在构建结束后重跑,1463 项通过、2 项跳过、29 个子测试通过。退出 1,失败的 3 项均为 `tests/test_upstream_technique_layers.py` 的基线失败。合并树的 `scripts/`、`tests/` 和 `references/` 与 staging 一致,沿用 #224 对这些文件的验收结果。
|
||||
- 合并后 TypeScript `--noEmit` 通过。前端全量按 `npm test` 同一文件清单和并发数对照,staging 3668 项中 3641 通过,本分支 3677 项中 3650 通过。两边均为相同的 27 项失败、0 项取消和跳过。保留全部基线测试名,首轮 7 项加返工 2 项均通过。最终隐私守卫 62 项通过。
|
||||
@@ -0,0 +1,112 @@
|
||||
# TASK:报告解读复查补事实核对规则,修正误拦,依据节被拦时给确定性审计(2026-11-12)
|
||||
|
||||
- Gitea:#218
|
||||
- 分支:`codex/reading-postcheck-facts-20261112`,基于 staging `2f374fff281622f342151a97fa6f6d758de74cc8`。#207 已合入(2f374fff)。
|
||||
- Bug 编号:BUG-1860
|
||||
- 验收:Claude(jyotisha-e6)
|
||||
|
||||
不得自行写「产品决定/裁定」。任务书没写到的取舍,一律选「需拍板」。
|
||||
|
||||
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
|
||||
|
||||
占星师的引擎和资料(`scripts/jyotish_engine.py`、`scripts/yoga_engine.py`、`references/` 下她仓库已有的文件、`SKILL.md`)一律不改。产品差异只能放进单独的产品文件,并在 `references/product_runtime_contract.md` 公告(项目规则 2026-11-07)。
|
||||
|
||||
占星师回复原文:`docs/tasks/ref-astrologer-acceptance-reply-20261112.md`,由本分支第一个提交带入。下文的「她 §N」都指这份文件。
|
||||
|
||||
## 背景
|
||||
|
||||
报告解读写完后,由 `frontend/src/lib/personal-report-reading.ts` 的 `checkSection` 逐节复查。没通过的那一节会被隐藏,原处显示 `READING_SECTION_WITHHELD_TEXT`。#207 已经补了三条复查:历史回放不给当前建议、双行运范围、形成与未验证。
|
||||
|
||||
她 §3 指出成稿里还有这些错误,要求修正,而不是降到 C 了事:
|
||||
|
||||
1. 「水星掌管第五宫与第十宫」:事实包 `planets.Mercury.owns_houses = [1,10]`,第五宫只是落宫(`d1_house = 5`)。这是宫主和落宫混用。
|
||||
2. 「SAV 总分为337,显示不缺维持力」:337 是固定的标准总和,只能用来校验,不能说明个人强弱。
|
||||
3. 「Badhaka(阻碍星)落在第七宫」:第七宫是阻碍宫,木星是阻碍宫主,两者混成了一个说法。
|
||||
4. Obama 样稿的健康节写了「运动或炎症类风险」:这是没有依据的具体现实风险。
|
||||
|
||||
她 §4 还指出两处问题:
|
||||
|
||||
5. `BANNED_PHRASES` 一刀切。「检测器检出,待核实」是合法的状态说明;「不得预测死亡」这类边界说明也不该被拦。
|
||||
6. 依据节被拦时整节隐藏,行动性结论还在页面上,证据限制却看不到了。
|
||||
|
||||
## 要改的文件
|
||||
|
||||
- `frontend/src/lib/personal-report-reading.ts`:只改复查部分
|
||||
- `frontend/src/lib/personal-report-reading-contract.ts`:只在第 6 条需要时新增类型或文案常量
|
||||
- `frontend/src/components/personal-report/report-reading-section.tsx`、`frontend/src/lib/report-export-blocks.ts`:只为第 6 条
|
||||
- 新建 `frontend/tests/report-reading-postcheck-facts-20261112.test.ts`
|
||||
- `docs/BUG_HISTORY.md`:末尾追加一条
|
||||
- `docs/tasks/PROGRESS-reading-postcheck-facts-20261112.md`
|
||||
|
||||
## 要做的
|
||||
|
||||
每条新检查不通过时只退回那一节,原因加进 `ReadingRejection`。
|
||||
|
||||
1. **`house_lord_mismatch`**
|
||||
- 句子里出现「X 掌管 / 主管 / 管 / 是 第 N 宫(主)」「第 N 宫主 X」「第 N 宫的宫主 X」「X 为第 N、M 宫主」等写法时,按事实包 `planets.<X>.owns_houses` 核对。N 不在其中就退回。
|
||||
- 「第 N 宫与第 M 宫」这类并列写法要逐个核对。
|
||||
- 「X 落 / 落在 / 位于 / 坐 第 N 宫」不是宫主写法,不查。
|
||||
- 行星名用 `PLANETS_ZH`。数字同时支持阿拉伯数字和中文数字(一到十二)。
|
||||
- 分句规则:以「。;!?」分句。宫主词与行星之间如果隔着另一颗行星名,就不算命中。
|
||||
2. **`sav_total_as_strength`**
|
||||
- 句子里出现「337」或「三百三十七」,同时又出现「不缺」「充足」「强」「弱」「维持力」「底气」「力量」中任何一个,退回。
|
||||
- 「校验」「合计」「总和为标准值」这类纯校验写法不退回。
|
||||
3. **`badhaka_role_mix`**
|
||||
- 健康领域的 Badhaka 值里有宫位和宫主。
|
||||
- 退回的情况:句子里出现「阻碍星」,同一分句又出现「第 N 宫」,而 N 是 Badhaka 宫的宫位。例如「阻碍星落在第七宫」。
|
||||
- 不退回的情况:「阻碍宫在第七宫」「阻碍宫主木星在第十宫」。
|
||||
- 字段名先按当前 `badhaka_facts.py` 的 `house` / `lord` 读取。如果 BUG-1859 已经合入,改为读 `badhaka_sthana` / `badhakesh`,两种都要兼容。
|
||||
4. **`health_outcome`**
|
||||
- 健康节和总评节里,命中具体病症、伤害或部位风险词表就退回。
|
||||
- 起始词表:炎症、感染、外伤、受伤、损伤、骨折、手术、出血、发炎、结石、肝、肾、心、肺、胃、血压、血糖、过敏、失眠、运动风险、运动损伤。
|
||||
- 「心」只在「心脏」「心血管」「心律」中算命中;「心理」「心情」「关心」等不算。
|
||||
- 写进 PROGRESS 的内容:完整词表,以及用现有三份公开样稿(下文路径)跑出的误伤列表。
|
||||
5. **`banned_phrase` 收窄**(只改这两个词,其他禁用词不动)
|
||||
- 「检测器」:只有出现在「检测器检出」「检测器识别」之后,并且同一分句含「待核实」「未确认」「候选」,才放行。其余照旧拦。
|
||||
- 「死亡」:同一分句里有「不得 / 不能 / 不会 / 不作 / 不做 / 不预测 / 禁止」中任何一个,并且出现在「死亡」之前,就放行。其余照旧拦。
|
||||
6. **依据节被拦时的确定性审计**
|
||||
- 当 `basis` 节被拦时,原处不再只显示「暂不显示」,而是显示固定生成的最小审计。内容只取自事实包的 `evidence_grades.claims`,不经过模型:
|
||||
- 每类结论一行:中文名、上限等级、必需依赖里缺失或在边界上的项,以及 `must_disclose` 列表;
|
||||
- 一句固定说明:「这一节的模型文字没有通过核对,下面是由计算直接生成的依据清单。」
|
||||
- 中文名和依赖项的中文写法放在 `personal-report-reading-contract.ts` 的常量里。不认识的 id 原样显示,不能编造。
|
||||
- 页面和 MD 导出(`report-export-blocks.ts`)都要换成这份审计。
|
||||
- 其他节被拦时,行为不变。
|
||||
7. 拦截正文里的准确率说法:句子里出现「成功率」「准确率」「命中率」,并且带百分数或「几成」,就退回,原因记为 `accuracy_claim`(她 §4 末条)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- 现有检查的逻辑,第 5 条除外
|
||||
- `writeReportReading`
|
||||
- `references/**`、`scripts/**`
|
||||
- 冻结文件
|
||||
|
||||
## 验收标准(写进新测试,全部用合成文字)
|
||||
|
||||
1. 第 1 到 5 条和第 7 条,每条至少测两种情况:一个触发、一个合法写法不触发。第 1 条还要测:「水星为第一、十宫主,落第五宫」不触发。
|
||||
2. 第 5 条:「检测器检出,待核实」不拦,「检测器显示」照旧拦;「本报告不得预测死亡」不拦,「有死亡风险」照旧拦。
|
||||
3. 第 6 条:`basis` 被拦时,页面渲染和 MD 导出都出现审计行,其他节不受影响。
|
||||
4. 用 `/workspace/eval-upstream-1107/report-192-acceptance-1112/报告解读层-验收材料-1112.zip` 里三份 `draft-*.raw.json` 与对应的 `facts-*.json` 跑复查:
|
||||
- A-2 的财富节(第 1、2 条)和健康节(第 3 条)必须被拦;
|
||||
- B-1 的健康节(第 4 条)必须被拦;
|
||||
- A-1 的依据节不能再因为「检测器」或「死亡」被拦(如果还因为别的原因被拦,把原因写进 PROGRESS)。
|
||||
- 这一条可以写成一次性脚本,结果写进 PROGRESS,不进测试。zip 里的 facts 不得提交进仓库。
|
||||
5. 已有的复查测试全部通过:`report-reading-20261027`、#207 的测试和 MD 导出测试。
|
||||
|
||||
## 自测命令(在 frontend 目录)
|
||||
|
||||
```
|
||||
npx tsx --test tests/report-reading-postcheck-facts-20261112.test.ts tests/report-reading-postcheck-gaps-20261112.test.ts tests/report-reading-20261027.test.tsx tests/report-md-export-reading-20261111.test.ts
|
||||
npx tsc --noEmit -p .
|
||||
npx eslint .
|
||||
```
|
||||
|
||||
另外在仓库根目录运行 Python 快速质量门:
|
||||
|
||||
```
|
||||
python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
|
||||
```
|
||||
|
||||
## 何时停下来选「需拍板」
|
||||
|
||||
- 第 4 条的词表在三份样稿里误伤了合法的结构描述(例如「第六宫主」),而且不放宽就无法避免。
|
||||
- 第 6 条需要改模型提示或事实包。
|
||||
@@ -649,6 +649,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
- **Export content:** 中文报告保存了解读时,导出列表在摘要之后列出「解读」,整篇和自选的默认正文均包含它。文件沿用页面的节标题、段落与「计算依据」标签。没有解读的报告沿用原正文。 Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
|
||||
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **Reading basis fallback (BUG-1860):** when 「依据与边界」 fails the post-check, the same section shows one explanation and a list with one calculation-backed line per claim. Each line names its ceiling, required evidence that is missing or near a boundary, and disclosures. The Markdown reading carries the same list. Other withheld sections keep their one-line placeholder.
|
||||
- **Technique audit status (BUG-1849):** newly generated English reports use readable calculation and interpretation phrases together in the Status cell, with the evidence qualifier when present. The reader also renders the earlier status format from saved reports.
|
||||
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. The selected segment keeps a neutral background; its text is `--color-action` and a 1px inset edge is `--color-action-strong`, matched on `[data-active]` or `[aria-selected="true"]`. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BASIS_AUDIT_INTRO,
|
||||
READING_SECTION_WITHHELD_TEXT,
|
||||
REPORT_READING_ANCHOR,
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
REPORT_READING_HEADING,
|
||||
basisAuditLine,
|
||||
evidenceGradeTag,
|
||||
readingSectionView,
|
||||
type ReportReading,
|
||||
@@ -38,7 +40,14 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
|
||||
<span className="personal-report-reading-number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||
<h3>{section.title}</h3>
|
||||
{view.status === "withheld" ? (
|
||||
<p className="personal-report-reading-withheld">{READING_SECTION_WITHHELD_TEXT}</p>
|
||||
view.basisAudit ? (
|
||||
<>
|
||||
<p className="personal-report-reading-withheld">{BASIS_AUDIT_INTRO}</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{view.basisAudit.map((row) => <li key={row.claimType}>{basisAuditLine(row)}</li>)}
|
||||
</ul>
|
||||
</>
|
||||
) : <p className="personal-report-reading-withheld">{READING_SECTION_WITHHELD_TEXT}</p>
|
||||
) : view.paragraphs.map((paragraph, paragraphIndex) => (
|
||||
<div className="personal-report-reading-paragraph" key={paragraphIndex}>
|
||||
<p>{paragraph.text}</p>
|
||||
|
||||
@@ -85,6 +85,13 @@ const storedParagraphV3 = z.strictObject({
|
||||
claimType: z.enum(REPORT_CLAIM_TYPES).optional(),
|
||||
grade: z.enum(EVIDENCE_GRADES).optional(),
|
||||
});
|
||||
const basisAuditRow = z.strictObject({
|
||||
claimType: z.string().min(1),
|
||||
cap: z.enum(EVIDENCE_GRADES),
|
||||
missingRequired: z.array(z.string()),
|
||||
mustDisclose: z.array(z.string()),
|
||||
});
|
||||
export type BasisAuditRow = z.infer<typeof basisAuditRow>;
|
||||
/** A section that failed the post-check is kept as a visible placeholder (owner 2026-11-12). */
|
||||
const storedSectionV3 = z.union([
|
||||
z.strictObject({
|
||||
@@ -98,6 +105,7 @@ const storedSectionV3 = z.union([
|
||||
title: z.string().min(1).max(12),
|
||||
status: z.literal("withheld"),
|
||||
reason: z.string().regex(/^[a-z_]{1,64}$/),
|
||||
basisAudit: z.array(basisAuditRow).optional(),
|
||||
}),
|
||||
]);
|
||||
/** Which of the astrologer's files the reading was written with (her §7.3: actually_loaded, not a path). */
|
||||
@@ -134,6 +142,41 @@ export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGr
|
||||
|
||||
/** Shown in place of a section that did not pass the post-check (owner 2026-11-12). */
|
||||
export const READING_SECTION_WITHHELD_TEXT = "这一节没有通过数据核对,暂不显示。";
|
||||
export const BASIS_AUDIT_INTRO = "这一节的模型文字没有通过核对,下面是由计算直接生成的依据清单。";
|
||||
const CLAIM_LABELS: Readonly<Record<ReportClaimType, string>> = {
|
||||
character_structure: "性格结构",
|
||||
career_promise: "事业倾向",
|
||||
wealth_earning: "获得收入",
|
||||
wealth_retention: "留住收入",
|
||||
relationship_structure: "关系结构",
|
||||
health_pressure: "健康压力",
|
||||
health_obstruction: "健康阻碍",
|
||||
timing_window: "时间窗口",
|
||||
};
|
||||
const DEPENDENCY_LABELS: Readonly<Record<string, string>> = {
|
||||
"v:D1": "本命盘上升边界", "v:D2": "财富分盘上升边界", "v:D6": "健康分盘上升边界",
|
||||
"v:D8": "D8 上升边界", "v:D9": "九分盘上升边界", "v:D10": "事业分盘上升边界", "v:D30": "D30 上升边界",
|
||||
"s:functional": "功能吉凶", "s:d9": "九分盘", "s:shadbala": "六力", "s:avastha": "星曜状态",
|
||||
"s:dispositor_chain": "定位星链", "s:ashtakavarga": "八分法", "s:argala": "助阻关系",
|
||||
"s:vimshottari": "Vimshottari 大运", "s:narayana": "Narayana 大运", "s:yogini": "Yogini 大运", "s:kp": "KP 判宫",
|
||||
"p:career:D10": "事业分盘", "p:career:AmK": "事业指标星(AmK)", "p:career:AK": "自我指标星(AK)",
|
||||
"p:career:Karakamsha": "Karakamsha", "p:career:A10": "事业映射点(A10)", "p:career:AL": "上升映射点(AL)",
|
||||
"p:career:D1_D9_D10": "本命、九分与事业分盘合看",
|
||||
"p:wealth:D2": "财富分盘(D2)", "p:wealth:D11": "财富参考分盘(D11)", "p:wealth:D10": "事业分盘(D10)",
|
||||
"p:wealth:A2": "第二宫映射点(A2)", "p:wealth:A11": "第十一宫映射点(A11)", "p:wealth:A10": "事业映射点(A10)",
|
||||
"p:marriage:D9_7": "九分盘第七宫", "p:marriage:UL": "婚姻映射点(UL)", "p:marriage:A7": "第七宫映射点(A7)",
|
||||
"p:marriage:DK": "伴侣指标星(DK)", "p:marriage:Vivah": "婚姻参考点(Vivah)",
|
||||
"p:health:D6": "健康分盘(D6)", "p:health:D8": "D8 分盘", "p:health:D30": "D30 分盘",
|
||||
"p:health:drekkana22": "第 22 个三分盘", "p:health:Badhaka": "阻碍宫和宫主",
|
||||
"t:transits": "行运", "t:annual": "年盘", "t:double_transit": "双行运",
|
||||
purpose_stability_not_evaluated: "未评估出生时间对这类判断的影响",
|
||||
};
|
||||
|
||||
export function basisAuditLine(row: BasisAuditRow): string {
|
||||
const claim = Object.hasOwn(CLAIM_LABELS, row.claimType) ? CLAIM_LABELS[row.claimType as ReportClaimType] : row.claimType;
|
||||
const labels = (ids: string[]) => ids.map((id) => Object.hasOwn(DEPENDENCY_LABELS, id) ? DEPENDENCY_LABELS[id] : id).join("、") || "无";
|
||||
return `${claim} · 上限 ${row.cap} · 缺失或临界的必需依据 ${labels(row.missingRequired)} · 必须说明 ${labels(row.mustDisclose)}`;
|
||||
}
|
||||
export const EVIDENCE_GRADE_LABEL = "证据等级";
|
||||
/**
|
||||
* The line under a graded paragraph (owner 2026-11-12, prototype option B): the
|
||||
@@ -150,9 +193,13 @@ export const EVIDENCE_GRADE_MEANING: Readonly<Record<EvidenceGrade, string>> = {
|
||||
/** One reading section as the page and the export show it, for every stored version. */
|
||||
export function readingSectionView(section: ReportReading["sections"][number]):
|
||||
| Readonly<{ status: "ok"; paragraphs: readonly ReportReadingParagraph[] }>
|
||||
| Readonly<{ status: "withheld" }> {
|
||||
| Readonly<{ status: "withheld"; basisAudit?: readonly BasisAuditRow[] }> {
|
||||
if (!("status" in section)) return { status: "ok", paragraphs: section.paragraphs.map((text) => ({ text })) };
|
||||
if (section.status === "withheld") return { status: "withheld" };
|
||||
if (section.status === "withheld") {
|
||||
return section.id === "basis" && section.basisAudit
|
||||
? { status: "withheld", basisAudit: section.basisAudit }
|
||||
: { status: "withheld" };
|
||||
}
|
||||
return { status: "ok", paragraphs: section.paragraphs.map(({ text, grade }) => (grade ? { text, grade } : { text })) };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MIN_TOTAL_CHARS,
|
||||
REPORT_READING_SECTIONS,
|
||||
reportReadingDraftSchema,
|
||||
type BasisAuditRow,
|
||||
type EvidenceGrade,
|
||||
type ReportReading,
|
||||
} from "./personal-report-reading-contract";
|
||||
@@ -33,10 +34,10 @@ export const REPORT_READING_TIMEOUT_MS = 120_000;
|
||||
/** Never in the reading: deterministic fate, medical, investment (AGENTS Part B4). */
|
||||
const BANNED_PHRASES = [
|
||||
"注定", "先苦后甜", "宿命", "命中注定",
|
||||
"癌", "肿瘤", "糖尿病", "高血压", "心脏病", "抑郁症", "焦虑症", "不孕", "流产", "早逝", "寿命", "死亡", "去世",
|
||||
"癌", "肿瘤", "糖尿病", "高血压", "心脏病", "抑郁症", "焦虑症", "不孕", "流产", "早逝", "寿命", "去世",
|
||||
"股票", "基金", "炒股", "期货", "比特币", "加密货币", "买入", "卖出", "抄底", "投资建议",
|
||||
// Internal wording the engine report leaked (research §4.1).
|
||||
"本仓", "检测器", "字段", "口径",
|
||||
"本仓", "字段", "口径",
|
||||
// One name per thing, same as the chat (罗睺 / 计都).
|
||||
"北交点", "南交点", "克图", "凯图",
|
||||
// Web search and case calibration stay backstage (product 2026-11-08, BUG-1794); the page shows the grade line.
|
||||
@@ -87,9 +88,14 @@ export type ReadingRejection =
|
||||
| "current_advice_in_replay"
|
||||
| "double_transit_scope"
|
||||
| "yoga_status_mismatch"
|
||||
| "house_lord_mismatch"
|
||||
| "sav_total_as_strength"
|
||||
| "badhaka_role_mix"
|
||||
| "health_outcome"
|
||||
| "accuracy_claim"
|
||||
| "all_sections_withheld";
|
||||
|
||||
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown }>>;
|
||||
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown; must_disclose?: unknown }>>;
|
||||
|
||||
function claimsOf(facts: Record<string, unknown> | null): Claims {
|
||||
const grades = facts?.evidence_grades as { contract?: unknown; claims?: unknown } | undefined;
|
||||
@@ -246,6 +252,96 @@ function dignityMismatch(text: string, dignities: Map<string, Set<string>>): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
const SENTENCE_END = /[。;!?]/;
|
||||
const DIVISIONAL_CHART = /\bD(?:[2-9]|[1-5]\d|60)\b|分盘|九分|事业盘|财富盘|健康盘|障碍盘/i;
|
||||
const PLANET_KEYS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] as const;
|
||||
const HOUSE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"];
|
||||
const HOUSE_NUMBER = "(?:1[0-2]|[1-9]|十[一二]?|[一二三四五六七八九])";
|
||||
const HOUSE_LIST = `第\\s*${HOUSE_NUMBER}(?:\\s*宫?\\s*(?:[、,,]|与|和|及|以及|或)\\s*第?\\s*${HOUSE_NUMBER})*\\s*宫`;
|
||||
const PLANET = `(${PLANETS_ZH.join("|")})`;
|
||||
const LORD_FORWARD = new RegExp(`${PLANET}\\s*(?:(?:掌管|主管|管(?:辖)?)\\s*(${HOUSE_LIST})|(?:是|为)\\s*(${HOUSE_LIST})\\s*(?:的\\s*宫)?主)`, "g");
|
||||
const LORD_BACKWARD = new RegExp(`(${HOUSE_LIST})\\s*(?:的\\s*宫|宫)?主\\s*(?:是|为|由)?\\s*${PLANET}`, "g");
|
||||
const NUMBERED_HOUSE = new RegExp(`第\\s*(${HOUSE_NUMBER})\\s*宫`, "g");
|
||||
const HOUSE_VALUES = new RegExp(HOUSE_NUMBER, "g");
|
||||
const SAV_TOTAL = /(?<![\d..])337(?![\d..])|三百三十七/;
|
||||
const SAV_STRENGTH = /不缺|充足|强|弱|维持力|底气|力量/;
|
||||
const HEALTH_OUTCOME = /炎症|感染|外伤|受伤|损伤|骨折|手术|出血|发炎|结石|肝|肾|心脏|心血管|心律|肺|胃|血压|血糖|过敏|失眠|运动风险/;
|
||||
const ACCURACY_WORD = /成功率|准确率|命中率/;
|
||||
const PERCENTAGE = /[0-90-9一二两三四五六七八九十百零〇](?:[..][0-90-9]+)?\s*[%%]|百分之\s*[0-90-9一二两三四五六七八九十百零〇]|[0-90-9一二两三四五六七八九十几数半]\s*成(?!功|立|为|人|长)/;
|
||||
const MISSING_DEPENDENCY = new Set([
|
||||
"blocked", "withheld", "missing", "calculation_failed", "missing_input", "not_computed", "boundary_sensitive",
|
||||
]);
|
||||
|
||||
function bannedQualifiedPhrase(sentence: string): boolean {
|
||||
for (const mention of sentence.split("检测器").slice(1)) {
|
||||
if (!/^(?:检出|识别|命中)/.test(mention) || !/待核实|未确认|候选/.test(mention)) return true;
|
||||
}
|
||||
for (const match of sentence.matchAll(/死亡/g)) {
|
||||
if (!/不得|不能|不会|不作|不做|不预测|禁止|不写|不谈|不涉及|不判断|不给|不讨论|不推断|避免/.test(sentence.slice(0, match.index))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function houseNumber(text: string): number {
|
||||
return /^\d+$/.test(text) ? Number(text) : HOUSE_NUMERALS.indexOf(text) + 1;
|
||||
}
|
||||
|
||||
function houseLordMismatch(sentence: string, facts: Record<string, unknown> | null): boolean {
|
||||
if (DIVISIONAL_CHART.test(sentence)) return false;
|
||||
const planets = facts?.planets as Record<string, { owns_houses?: unknown }> | undefined;
|
||||
const conflicts = (name: string, houses: string) => {
|
||||
const index = PLANETS_ZH.findIndex((planet) => planet === name);
|
||||
const key = PLANET_KEYS[index];
|
||||
const owns = key ? planets?.[key]?.owns_houses : undefined;
|
||||
return Array.isArray(owns) && [...houses.matchAll(HOUSE_VALUES)].some(([house]) => !owns.includes(houseNumber(house)));
|
||||
};
|
||||
for (const [, planet, managedHouses, lordHouses] of sentence.matchAll(LORD_FORWARD)) {
|
||||
if (conflicts(planet, managedHouses ?? lordHouses)) return true;
|
||||
}
|
||||
for (const [, houses, planet] of sentence.matchAll(LORD_BACKWARD)) {
|
||||
if (conflicts(planet, houses)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function badhakaHouse(facts: Record<string, unknown> | null): unknown {
|
||||
const domains = facts?.domains as { health?: { participation?: unknown } } | undefined;
|
||||
const participation = domains?.health?.participation;
|
||||
if (!Array.isArray(participation)) return undefined;
|
||||
const value = participation.find((row) => row?.id === "Badhaka")?.value;
|
||||
return value?.badhaka_sthana?.house ?? value?.house;
|
||||
}
|
||||
|
||||
function checkedFactClaim(text: string, section: string, facts: Record<string, unknown> | null): ReadingRejection | undefined {
|
||||
const obstructionHouse = badhakaHouse(facts);
|
||||
for (const sentence of text.split(SENTENCE_END)) {
|
||||
if (houseLordMismatch(sentence, facts)) return "house_lord_mismatch";
|
||||
if (SAV_TOTAL.test(sentence) && SAV_STRENGTH.test(sentence)) return "sav_total_as_strength";
|
||||
if (sentence.includes("阻碍星") && [...sentence.matchAll(NUMBERED_HOUSE)].some(([, house]) => houseNumber(house) === obstructionHouse)) {
|
||||
return "badhaka_role_mix";
|
||||
}
|
||||
if ((section === "health" || section === "overview") && HEALTH_OUTCOME.test(sentence)) return "health_outcome";
|
||||
if (ACCURACY_WORD.test(sentence) && PERCENTAGE.test(sentence)) return "accuracy_claim";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function basisAudit(claims: Claims): BasisAuditRow[] {
|
||||
const rows: BasisAuditRow[] = [];
|
||||
for (const [claimType, claim] of Object.entries(claims)) {
|
||||
const cap = EVIDENCE_GRADES.find((grade) => grade === claim?.cap);
|
||||
if (!claimType || !cap) continue;
|
||||
const missingRequired = Array.isArray(claim.required)
|
||||
? claim.required.filter((dep) => typeof dep?.id === "string" && MISSING_DEPENDENCY.has(dep.state)).map((dep) => dep.id as string)
|
||||
: [];
|
||||
const mustDisclose = Array.isArray(claim.must_disclose)
|
||||
? claim.must_disclose.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
rows.push({ claimType, cap, missingRequired, mustDisclose });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
type DraftSection = Readonly<{
|
||||
id: string;
|
||||
paragraphs: ReadonlyArray<{ text: string; claim_type: string | null; grade: EvidenceGrade | null }>;
|
||||
@@ -264,7 +360,7 @@ function checkSection(
|
||||
facts: Record<string, unknown> | null,
|
||||
): { section: CheckedSection; chars: number } {
|
||||
const withheld = (reason: ReadingRejection) => ({
|
||||
section: { id: section.id, title, status: "withheld" as const, reason },
|
||||
section: { id: section.id, title, status: "withheld" as const, reason, ...(section.id === "basis" ? { basisAudit: basisAudit(claims) } : {}) },
|
||||
chars: 0,
|
||||
}) as { section: CheckedSection; chars: number };
|
||||
const graded = (GRADED_SECTION_IDS as readonly string[]).includes(section.id);
|
||||
@@ -277,12 +373,16 @@ function checkSection(
|
||||
if (!text) return withheld("too_short");
|
||||
if (text.length > MAX_PARAGRAPH_CHARS) return withheld("too_long");
|
||||
if (/[_={}<>#*|`]/.test(text)) return withheld("field_name");
|
||||
if (BANNED_PHRASES.some((phrase) => text.includes(phrase)) || CERTAINTY.test(text)) return withheld("banned_phrase");
|
||||
if (BANNED_PHRASES.some((phrase) => text.includes(phrase)) || CERTAINTY.test(text) || text.split(SENTENCE_END).some(bannedQualifiedPhrase)) {
|
||||
return withheld("banned_phrase");
|
||||
}
|
||||
if (dignityMismatch(text, dignities)) return withheld("dignity_mismatch");
|
||||
if (falseMissingLayer(text, computed)) return withheld("false_missing_layer");
|
||||
if (currentAdviceInReplay(text, section.id, facts)) return withheld("current_advice_in_replay");
|
||||
if (doubleTransitOutOfScope(text, section.id, facts)) return withheld("double_transit_scope");
|
||||
if (yogaStatusMismatch(text, facts)) return withheld("yoga_status_mismatch");
|
||||
const factRejection = checkedFactClaim(text, section.id, facts);
|
||||
if (factRejection) return withheld(factRejection);
|
||||
disclaimers += text.match(DISCLAIMER)?.length ?? 0;
|
||||
chars += text.length;
|
||||
if (!graded) {
|
||||
|
||||
@@ -9,9 +9,11 @@ import type { ReportLanguage } from "./report-language";
|
||||
import { SIGN_ZH, type SignName } from "./chart-view-labels";
|
||||
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
|
||||
import {
|
||||
BASIS_AUDIT_INTRO,
|
||||
READING_SECTION_WITHHELD_TEXT,
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
REPORT_READING_HEADING,
|
||||
basisAuditLine,
|
||||
evidenceGradeTag,
|
||||
readingSectionView,
|
||||
type ReportReading,
|
||||
@@ -91,7 +93,11 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
|
||||
`## ${REPORT_READING_HEADING}`,
|
||||
...reading.sections.flatMap(section => {
|
||||
const view = readingSectionView(section);
|
||||
// #192: the export carries the same grade tags and withheld line as the page.
|
||||
if (view.status === "withheld" && view.basisAudit) {
|
||||
const lines = view.basisAudit.map((row) => `- ${basisAuditLine(row).replace(/[\\`*_[\]<>&]/g, "\\$&").replace(/[\r\n]+/g, " ")}`);
|
||||
return [`### ${section.title}`, BASIS_AUDIT_INTRO, lines.join("\n")].filter(Boolean);
|
||||
}
|
||||
// The export carries the same grade tags and fallback as the page.
|
||||
return [`### ${section.title}`, ...(view.status === "withheld"
|
||||
? [READING_SECTION_WITHHELD_TEXT]
|
||||
: view.paragraphs.map(paragraph => paragraph.grade ? `${paragraph.text}\n\n${evidenceGradeTag(paragraph.grade)}` : paragraph.text))];
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ReportReadingSection } from "../src/components/personal-report/report-reading-section";
|
||||
import {
|
||||
acceptReportReadingDraft,
|
||||
REPORT_READING_SECTIONS,
|
||||
reportReadingSchema,
|
||||
type ReadingRejection,
|
||||
} from "../src/lib/personal-report-reading";
|
||||
import { BASIS_AUDIT_INTRO, EVIDENCE_GRADE_CONTRACT, READING_SECTION_WITHHELD_TEXT } from "../src/lib/personal-report-reading-contract";
|
||||
import { buildReportExportBlocks } from "../src/lib/report-export-blocks";
|
||||
|
||||
type Section = (typeof REPORT_READING_SECTIONS)[number]["id"];
|
||||
const NEUTRAL = "这一段只说明虚构材料的记录顺序以及供比对的线索。叙述保留记录本身的范围和时间,也指出没有得到回答的问题。这里的细节用于检验逐节复查的行为。".repeat(2);
|
||||
const CLAIMS: Partial<Record<Section, string>> = {
|
||||
character: "character_structure", career: "career_promise", wealth: "wealth_earning",
|
||||
love: "relationship_structure", health: "health_pressure", now: "timing_window",
|
||||
};
|
||||
const FACTS = {
|
||||
planets: { Mercury: { owns_houses: [1, 10], d1_house: 5 }, Jupiter: { owns_houses: [7, 12], d1_house: 10 } },
|
||||
domains: { health: { participation: [{ id: "Badhaka", value: { badhaka_sthana: { house: 7 }, badhakesh: { planet: "Jupiter", house: 10 } } }] } },
|
||||
evidence_grades: {
|
||||
contract: EVIDENCE_GRADE_CONTRACT,
|
||||
claims: {
|
||||
...Object.fromEntries(Object.entries(CLAIMS).map(([section, claim]) => [claim, { cap: "C", sections: [section] }])),
|
||||
wealth_earning: {
|
||||
cap: "C", sections: ["wealth"],
|
||||
required: [{ id: "v:D2", state: "boundary_sensitive" }, { id: "v:D1", state: "clear_at_tolerance" }],
|
||||
must_disclose: ["s:shadbala", "unknown_dependency"],
|
||||
},
|
||||
unknown_claim: { cap: "D", required: [{ id: "custom:missing", state: "not_computed" }], must_disclose: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function reading(overrides: Partial<Record<Section, string>>, facts: Record<string, unknown> = FACTS) {
|
||||
const sections = REPORT_READING_SECTIONS.map(({ id }) => ({
|
||||
id,
|
||||
paragraphs: [NEUTRAL, overrides[id] ?? NEUTRAL].map((text) => ({
|
||||
text, claim_type: CLAIMS[id] ?? null, grade: CLAIMS[id] ? "C" : null,
|
||||
})),
|
||||
}));
|
||||
const result = acceptReportReadingDraft({ sections }, facts, {
|
||||
name: "reading", version: "test", loaded: [{ path: "SKILL.md", sha256: "0".repeat(64) }],
|
||||
});
|
||||
assert.ok(result.ok && result.reading.version === 3, JSON.stringify(result));
|
||||
return result.reading;
|
||||
}
|
||||
|
||||
function verify(section: Section, text: string, reason?: ReadingRejection, facts: Record<string, unknown> = FACTS) {
|
||||
const checked = reading({ [section]: text }, facts).sections;
|
||||
assert.deepEqual(checked.filter((part) => part.status === "withheld").map((part) => [part.id, part.reason]),
|
||||
reason ? [[section, reason]] : [], text);
|
||||
}
|
||||
|
||||
test("house lord checks every listed house using ownership and skips placement", () => {
|
||||
for (const text of ["水星掌管第五宫与第十宫。", "第五宫的宫主水星。", "水星主管第1宫、5宫。", "水星为第五、十宫主。"]) {
|
||||
verify("wealth", text, "house_lord_mismatch");
|
||||
}
|
||||
for (const text of ["水星为第一、十宫主,落第五宫。", "第十宫主水星位于第五宫。", "水星坐第5宫。", "水星在这里,木星掌管第十二宫。", "第七宫主是木星,水星落在第五宫。", "水星是第五宫里的参考星。"] ) {
|
||||
verify("wealth", text);
|
||||
}
|
||||
});
|
||||
|
||||
test("divisional house lords skip natal ownership only within the marked sentence", () => {
|
||||
const differentNatalLords = { ...FACTS, planets: { ...FACTS.planets, Saturn: { owns_houses: [2, 3] }, Moon: { owns_houses: [8] } } };
|
||||
verify("career", "D10 上升白羊,其第十宫主土星在 D10 第四宫。", undefined, differentNatalLords);
|
||||
verify("love", "D9 婚姻分盘内,第七宫主月亮落在双鱼。", undefined, differentNatalLords);
|
||||
for (const chart of ["D2", "D60", "分盘", "九分", "事业盘", "财富盘", "健康盘", "障碍盘"]) {
|
||||
verify("wealth", `${chart}里,水星掌管第五宫与第十宫。`);
|
||||
}
|
||||
for (const text of ["本命里,水星掌管第五宫与第十宫。", "D1 里,水星掌管第五宫与第十宫。",
|
||||
"上一句提到 D10。水星掌管第五宫与第十宫。"]) {
|
||||
verify("wealth", text, "house_lord_mismatch");
|
||||
}
|
||||
});
|
||||
|
||||
test("standard SAV total is a checksum and cannot stand in for individual strength", () => {
|
||||
verify("wealth", "SAV 总分为337,显示不缺维持力。", "sav_total_as_strength");
|
||||
verify("wealth", "三百三十七说明力量充足。", "sav_total_as_strength");
|
||||
verify("wealth", "SAV 合计337,与标准值一致。另有一项力量记录。 ");
|
||||
});
|
||||
|
||||
test("Badhaka separates the obstruction house from the lord's placement in both packet shapes", () => {
|
||||
verify("health", "阻碍星落在第七宫。", "badhaka_role_mix");
|
||||
verify("health", "阻碍宫在第七宫。阻碍宫主木星在第十宫。");
|
||||
verify("health", "阻碍星落在第7宫。", "badhaka_role_mix", {
|
||||
...FACTS,
|
||||
domains: { health: { participation: [{ id: "Badhaka", value: { house: 7, lord: "Jupiter" } }] } },
|
||||
});
|
||||
});
|
||||
|
||||
test("health and overview withhold specific risks while ordinary structural language stays", () => {
|
||||
verify("health", "运动或炎症类风险。", "health_outcome");
|
||||
verify("overview", "这里预测心律问题。", "health_outcome");
|
||||
verify("health", "第六宫主所在位置已算出。心理、心情和关心是三种说法。");
|
||||
verify("wealth", "这个合成句用炎症作为排版词。");
|
||||
});
|
||||
|
||||
test("detector and death wording keep only sentence-local qualified mentions", () => {
|
||||
verify("basis", "检测器检出,待核实。检测器识别的组合是候选。本报告不得预测死亡。");
|
||||
for (const text of ["检测器显示。", "检测器检出。另一条待核实。", "有死亡风险。", "死亡不预测。", "不得夸大。还有死亡风险。"]) {
|
||||
verify("basis", text, "banned_phrase");
|
||||
}
|
||||
verify("basis", "检测器识别的结果未确认,但本仓有对应记录。", "banned_phrase");
|
||||
});
|
||||
|
||||
test("qualified detector hits and explicit limits on discussing death pass the basis check", () => {
|
||||
for (const separator of ["/", "、", ","]) {
|
||||
verify("basis", `合成格局的形成状态仍是检测器命中${separator}待核实。`);
|
||||
}
|
||||
for (const boundary of ["不写", "不谈", "不涉及", "不判断", "不给", "不讨论", "不推断", "避免"]) {
|
||||
verify("basis", `本段${boundary}疾病名、诊断、死亡或离婚的结论。`);
|
||||
}
|
||||
for (const text of ["检测器显示为候选。", "待核实的格局由检测器命中。", "检测器命中。其余格局待核实。",
|
||||
"检测器命中,检测器识别的组合待核实。", "有死亡风险。", "不写疾病。仍然有死亡风险。"]) {
|
||||
verify("basis", text, "banned_phrase");
|
||||
}
|
||||
});
|
||||
|
||||
test("percentages and fractions expressed as 成 cannot support accuracy claims", () => {
|
||||
for (const text of ["成功率达85%。", "准确率接近九成。", "命中率为百分之九十。", "命中率有几成。"] ) {
|
||||
verify("overview", text, "accuracy_claim");
|
||||
}
|
||||
verify("overview", "没有计算准确率。另一项占九成。");
|
||||
});
|
||||
|
||||
test("withheld basis persists a fact-only audit for the page and Markdown", () => {
|
||||
const checked = reading({ basis: "检测器显示。", health: "这里预测骨折。" });
|
||||
const stored = reportReadingSchema.parse(JSON.parse(JSON.stringify(checked)));
|
||||
const rendered = renderToStaticMarkup(createElement(ReportReadingSection, { reading: stored }));
|
||||
const markdown = buildReportExportBlocks({ markdown: "# 合成资料", reading: stored }).find((block) => block.id === "body:reading")!.text;
|
||||
for (const output of [rendered, markdown]) {
|
||||
assert.ok(output.includes(BASIS_AUDIT_INTRO));
|
||||
assert.ok(output.includes("获得收入 · 上限 C · 缺失或临界的必需依据 财富分盘上升边界 · 必须说明 六力"));
|
||||
assert.ok(output.includes("custom:missing"));
|
||||
assert.equal(output.split(READING_SECTION_WITHHELD_TEXT).length - 1, 1);
|
||||
assert.ok(!output.includes("检测器显示") && !output.includes("本命盘上升边界"));
|
||||
}
|
||||
assert.match(rendered, /unknown_claim.*unknown_dependency|unknown_dependency.*unknown_claim/);
|
||||
assert.ok(markdown.includes("unknown\\_claim") && markdown.includes("unknown\\_dependency"));
|
||||
assert.ok(!JSON.stringify(reading({})).includes("basisAudit"));
|
||||
});
|
||||
Reference in New Issue
Block a user