Merge #219 per-paragraph claim binding with auto-bound references (BUG-1862)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m13s
Independent Staging Quality Gate / validate-python (push) Successful in 6m33s
Independent Staging Quality Gate / publish (push) Successful in 2m54s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
This commit is contained in:
2026-11-13 03:58:26 +08:00
co-authored by Claude Opus 5.5
19 changed files with 742 additions and 92 deletions
+4
View File
@@ -1,5 +1,9 @@
# BLOCKED
## BUG-1862 · 模型稿回放(#219,2026-11-13)
- ~~模型稿待回放。~~ Claude 已回放,结果见 [#219 修复单](docs/tasks/FIX-reading-claim-binding-1.md)。修复后的重新生成与回放由独立验收执行。
## TASK-voice-reading-20261027:真实模型输出没看(issue #113,2026-10-27)
- **报告解读**:本机没有 `DS_KEY`、`BACKTEST_MODEL_KEY`、`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`。解读的形状、校验、失败兜底、记账都用假模型测过;真实模型写出来的解读是什么样、几成能一次过校验、耗时多少,都没测,不得写成通过。
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-11-13 — 个人报告逐段核对结论与依据
- 总评和建议沿用正文通过核对的结论等级。含时间窗口或引用分盘的判断按所用依据复查;页面和 Markdown 标明综合了几类结论。
- 沿用当前 Skill 版本(BUG-1862)。
## 2026-11-13 — 个人报告按出生时间范围核对分盘边界
- 出生分钟未确认时,个人报告用填报的时间范围核对各分盘上升。误差未知时按前后各 30 分钟。必需分盘在范围内换座的结论暂不能判断。
+15
View File
@@ -25144,6 +25144,21 @@
- 复发自:无。
- 修复版本:`codex/reading-postcheck-facts-20261112`,待发布。
## BUG-1862 | 个人报告的摘要、时间窗口和分盘引用绕过结论分级
- 状态:fixed-pending-verify(待独立验收)。
- 验收退回:真实模型稿被过严规则整份拦下,改为由代码补全引用。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-13。
- 影响面:中文个人报告的写作格式、逐节复查、解读页面和 Markdown。
- 现象:总评和建议可给出未分级判断。事业段可引用时间窗口却只按本命事业分级。健康段可借用临界 D8,却只按阻碍宫分级。
- 触发条件:一段混合多类判断、点名未绑定的层,或在无需分级的章节写入实质判断。
- 根因:原草稿只给六个领域节标单个结论类型。段落未声明实际引用的层,复查也无法按引用核对稳定性。
- 修复:事实包为依赖提供与结论账一致的状态和别名。九节逐段区分事实与判断,校验实际引用、时间判断及最严上限。总评和建议继承正文通过的等级。页面和导出共用等级显示。
- 验证:合成用例覆盖摘要继承、时间窗口、临界 D8、事实段以及页面和 Markdown。测试结果与样稿复查范围见 `docs/tasks/PROGRESS-reading-claim-binding-20261112.md`。
- 防复发:后端断言目录复用分级求值,前端断言违规仅影响对应节,并保留旧存储版本的渲染回归。
- 关联记录:#219、BUG-1860、BUG-1864、BUG-1858。
- 修复版本:`codex/reading-claim-binding-20261112`,待发布。
## BUG-1864 | 个人报告未确认出生分钟时漏查分盘边界
- 状态:fixed-pending-verify(待独立验收)。
+70
View File
@@ -0,0 +1,70 @@
# #219 验收退回:真实模型稿全部过不了,规则一部分过严,引用由代码补全(2026-11-13,Claude)
本文件补充 `TASK-reading-claim-binding-20261112.md`,两者冲突时以本文件为准。
验收对象是 `42fb5a6a`。
已核对通过、不要改动的部分:
- 依赖目录(复用 `_evaluate`)
- v4 存储
- 「综合 N 类结论」显示
- 旧版本兼容
- `fact_with_inference`
- `ref_unstable`
- 摘要与建议只引用已通过的结论
## 真实模型回放(验收主机,DeepSeek v4 pro,Jobs 与 Obama 两张公开盘各两稿,脱敏)
四稿全部整份退回,原因都是 `too_short`:被拦的节太多,剩下的字数低于下限。逐节原因如下:
| 稿 | 被拦的节 |
|---|---|
| A-1 | overview: claim_not_established;wealth: ref_outside_claims;love、now、basis: timing_unbound;health、advice: current_advice_in_replay |
| A-2 | overview: claim_not_established;career、wealth: undeclared_dependency;now、basis: timing_unbound;health、advice: current_advice_in_replay |
| B-1 | overview、advice: claim_not_established;career、wealth: undeclared_dependency;love、now: timing_unbound;basis: banned_phrase(「口径」,应拦) |
| B-2 | overview、advice: claim_not_established;wealth、love: undeclared_dependency;character、basis: timing_unbound;health: ref_outside_claims |
其中有模型真的漏写(应拦),也有规则过严(误拦)。逐条拆开看:
1. **`timing_unbound` 拦了纯事实段(误拦)。**「依据与边界」逐条列出所用的技法名(Vimshottari、Narayana、大运),「现在这几年」把运程起止列成事实段。这些段是 `kind: fact`,没有判断,按规则就不该要求 `timing_window`。
2. **一层只能对应一个依赖 id(误拦)。**
- 正文出现「D11」,只能对应到 `v:D11`。可 `wealth_earning` 允许的是 `p:wealth:D11`,没有一类结论能合法提到 D11。
- 正文出现「九分盘」,只能对应到 `v:D9`。`career_promise` 只允许 `s:d9`,事业段不能提九分盘。
3. **要模型逐层抄写 refs(大量误拦)。** 模型正文点名了 D1、D9、大运、过境,refs 里却漏填了。按原任务书的要求,这一处就该拦,但模型几乎每稿都会漏一两处。refs 本来就能从正文机械识别出来,交给模型抄写,只会让整份报告过不了。
## 改法
1. **`timing_unbound` 只查判断段**,不查 `kind: fact` 段。事实段仍由 `fact_with_inference` 管,不得夹带推断。
2. **一个说法可以对应多个依赖 id,命中任何一个都算。** 在 `DEPENDENCY_ALIASES` 和依赖目录里调整:
- 分盘代号 `Dn` 同时对应 `v:Dn` 和所有 `p:<domain>:Dn`;
- 「九分盘」「Navamsha」「D9」同时对应 `v:D9`、`s:d9`、`p:marriage:D9_7`;
- 「本命盘」「D1」对应 `v:D1`。
- 判断段允许引用的范围,仍是所列结论 required 加 checked 的并集;命中的 id 只要有一个在这个并集里就算合法。
- 这样调整后,误拦的那几处(正文提到 D11、事业段提到九分盘)就能合法写出来;在任何一类结论里都不允许出现的层,照样退回 `ref_outside_claims`。
3. **refs 由代码补全,不再要求模型写全。**
- 判断段的有效引用 = 模型给的 refs ∪ 从正文识别出的说法所对应的依赖 id。识别出的说法对应多个 id 时,取落在允许并集里的那些。
- `undeclared_dependency` 改为只在一种情况下触发:正文提到的层,对应的 id 没有一个在允许并集里。这种情况可以和 `ref_outside_claims` 合并,保留哪个名字自己选,写进 PROGRESS。
- `ref_unstable` 按补全后的有效引用计算:正文提到了处在边界上的分盘,而等级不是 D,就退回。这一条是她 §3 的 D8 要求,必须保留。
- 存储结果里保存补全后的有效引用(例如 `refs`),并另存一份 `refs_from_text`,记下哪些是从正文识别补上的。
4. **提示**:合同里 refs 的说明改成「列出主要依据即可,正文点名的层由系统自动绑定」。正文点名的层会自动计入等级判断这一点,要写清楚。只改这一处。
## 验收
1. 新增的合成测试:
- 事实段列出大运起止,通过;
- 判断段写了年份,却没带 `timing_window`,仍然退回;
- `wealth_earning` 段提到 D11,通过;
- `career_promise` 段提到九分盘,通过;
- 判断段漏写 refs,但正文点名的是允许并集内的稳定层,通过,并且存储里的 `refs_from_text` 有记录;
- 正文点名了处在边界上的 D8,等级是 C,退回 `ref_unstable`;
- 正文点名的层在任何所列结论里都不允许,退回。
2. 原有测试全部通过。
3. 我在验收主机上,用同样的两张盘重新生成并回放。预期:
- 大部分节通过,不再整份 `too_short`;
- 仍然会被拦的,只能是真错:已故者的当前建议、「口径」这类禁用词、事实错误。
## 不要碰
原任务书的「不要碰」清单继续有效。BLOCKED.md 中 BUG-1862 的条目,在本轮改为「Claude 已回放,结果见本文件」。
BUG-1862:在原条目补一行「验收退回:真实模型稿被过严规则整份拦下,改为由代码补全引用」,只改这一条。
@@ -0,0 +1,72 @@
# 个人报告逐段绑定结论与依赖
- Gitea #219,BUG-1862
- 执行 Codex,独立验收 Claude
- 基线 `origin/staging` `65fab6ebdfd6bc5c22324c6857af562097a99d3e`
## 首轮实施(`42fb5a6a`)
- `evidence_grades.dependency_catalog` 复用 `_evaluate`,覆盖结论规则的必需和已检查依赖,以及事实包各领域实际提供的 D1–D60。分盘边界沿用 #224 的出生时间范围。
- 九节逐段用 `text/claims/grade/refs` 或 `text/kind: fact`。模型输出先验九节结构,再逐节核对段落绑定。总评和建议只继承六个领域节已经通过的结论。同一类型在多段通过时取其中最严格的等级。
- v4 保存逐段绑定。网页与 Markdown 使用同一等级行,多个结论附「(综合 N 类结论)」,纯事实没有等级行。继续读取 v1–v3,保留「依据与边界」退回时的事实清单。
- 新生成解读需读到依赖目录。滚动升级期间若收到旧事实包,继续交付计算依据,并沿用解读暂不可用的处理。
- 判断段的引用只能来自已列结论的必需与已检查依赖。缺失、边界敏感或尚未评估的引用要求 D。分盘正文别名绑定 `v:Dn`,因此明确引用分盘时会核对其精度。现有结论路由未授权某个分盘的精度项时,引用会被退回。
## 首轮词表
| 依赖 | 正文别名 |
| --- | --- |
| `v:D1`、`v:D2`、`v:D6`、`v:D9`、`v:D10` | 对应分盘代号和本命盘、财富分盘、健康分盘、九分盘 / Navamsha、事业分盘 |
| 其他目录内 `v:Dn` | 对应分盘代号 |
| `s:functional`、`s:d9` | 功能吉凶、九分盘尊贵度 |
| `s:shadbala`、`s:avastha`、`s:dispositor_chain` | Shadbala / 六力、Avastha / 星曜状态、定位星链 |
| `s:ashtakavarga`、`s:argala` | Ashtakavarga / 八分法 / SAV、Argala |
| `s:vimshottari`、`s:narayana`、`s:yogini`、`s:kp` | Vimshottari / 大运 / 小运 / 次小运、Narayana / 纳拉亚纳、Yogini、KP / KP 判宫 |
| `t:transits`、`t:annual`、`t:double_transit` | 过境 / 行运、年盘、Double Transit / 双行运 / 双重行运 / 双星过境 |
| 事业、财富、感情参与项 | AmK、AK、Karakamsha、A10、AL、A2、A11、九分盘第七宫、UL、A7、DK、Vivah |
| `p:health:drekkana22`、`p:health:Badhaka` | 第22个Drekkana / 第22个三分盘、Badhaka / 阻碍宫 / 阻碍宫主 |
同一位置的重叠别名先匹配较长词。A10 同时属于事业和财富时,使用所列结论允许的 A10 依赖。分盘参与项保留各自的状态。
纯事实段的推断词表为「意味着」「显示出」「适合」「容易」「有利」「不利」「倾向」「说明你」。仓库 Markdown 导出样稿是已存 v2,九节共 18 段,未命中这些词、时间标记或目录别名。样稿继续走原来的渲染。三份模型评估原稿需在独立验收主机回放,仓库样稿只覆盖存储兼容和普通措辞。
## 现有断言调整
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `report-reading-20261027.test.tsx` 的干净草稿与写作成功 | 新写入为 v3 | 新写入为 v4 | 新存储保存逐段绑定 |
| 同文件的缺少结论类型 | `career:claim_type_missing` | `career:claim_binding_missing` | 两种新形态均不成立时的退回原因 |
| 同文件的专业技法写法 | 含时间的段落仅绑定事业 | 同一段增加 `timing_window`,仍断言通过 | 时间窗口纳入分级 |
| `report-reading-postcheck-gaps-20261112.test.ts` 与 `report-reading-postcheck-facts-20261112.test.ts` | 新写入为 v3 | 新写入为 v4 | 保留原复查断言,合成草稿改用新形态 |
## 首轮验证
- Python 定向 20 项通过,覆盖依赖目录、分级、用途稳定性及出生时间误差范围。
- 前端定向 46 项通过,取消 0。覆盖摘要继承、时间窗口、临界 D8、纯事实、已有逐节复查和存储兼容。
- Windows 前端全量使用与基线同样的四路并发及 TAP 报告。候选 3,681 项、3,654 通过、27 失败、0 取消。基线 3,677 项、3,650 通过、27 失败、0 取消。失败名称集合一致,无测试消失,新增 4 项均通过。
- 最终 TypeScript 检查与 Next 构建通过。`/` 为 Static。首屏 JS gzip 为 515,860 字节,CSS gzip 为 90,906 字节,均与基线相同。
- ESLint 0 错误、62 条既有警告。
- Python 快速门 1,463 项通过、2 项跳过,退出码 1。失败的 3 项均在 `test_upstream_technique_layers.py`,为 Windows 基线同名的 PyJHora 用例:`test_public_chart_values_are_deterministic_and_pinned`、`test_dasha_registry_serves_or_fails_closed`、`test_kaala_matches_pyjhora_seed_and_periods`。
- 隐私标记测试 62 项通过。
## 第一次返工
- Claude 回放四份模型稿后的脱敏结果见 [修复单](FIX-reading-claim-binding-1.md)。本轮沿用 #230。时间文本检查只在判断段生效。事实段仍执行原有推断、历史回放和事实核对。
- 目录继续复用 `_evaluate`。分盘代号匹配同名的上升边界项和领域参与项,D1 仍只绑定 `v:D1`。九分盘、Navamsha 和 D9 同时匹配 `v:D9`、`s:d9` 与 `p:marriage:D9_7`。只加入当前结论允许的匹配项。
- 模型可省略 `refs`。服务端保存显式和自动引用的并集,并在 `refs_from_text` 记录自动补上的项目。沿用 v4,已保存的 v4 也可省略这个新字段。自动补全后仍按有效引用核对缺失与边界。
- 判断段点名没有任何允许依赖的层,与显式引用超出允许范围使用同一原因 `ref_outside_claims`。
| 既有断言 | 原值 | 新值与原因 |
| --- | --- | --- |
| 摘要依赖未通过的事业段 | 事业段遗漏 D10 引用,退回 `undeclared_dependency` | 事业段改提路由不允许的 D8,退回 `ref_outside_claims`。摘要仍因正文未通过而退回 |
| 健康段只绑定阻碍、点名 D8 | `undeclared_dependency` | `ref_outside_claims`,正文和显式引用统一检查允许范围 |
| 健康段绑定阻碍和压力、引用临界 D8 | 显式带 D8 时 C 退回、D 通过 | 省略显式引用仍得到相同结果,且 D 段保存自动补全的两项依赖 |
### 本轮验证
- Python 定向 21 项通过,前端定向 48 项通过,取消 0。覆盖大运起止事实、判断段的时间绑定、D11、九分盘、自动引用、临界 D8、非法引用和旧 v4 回读。
- TypeScript 与 Next 构建通过。首页为 Static,首屏 JS/CSS gzip 与 staging 基线相同。
- ESLint 0 错误、62 条既有警告。隐私标记测试 62 项通过。
- Python 快速门 1,462 项通过、2 跳过、4 失败。3 项 PyJHora 与基线一致。另 1 项为 `test_the_new_layer_leaves_every_existing_output_unchanged` 的 VedAstro 返回差异,单独复跑通过。
- Windows 前端全量 3,683 项,3,645 通过、38 失败、0 取消。staging 基线 3,677 项,3,650 通过、27 失败、0 取消。27 项同名失败,未移除任何测试。额外的 11 项均在数据库套件,其中 10 项在串行复跑通过,剩余合盘数据库用例在单文件复跑通过。对应的 staging 串行对照 14 项全通过。
- Claude 需在验收主机按修复单重新生成两张公开盘的四份模型稿,核对误拦变化。
@@ -0,0 +1,102 @@
# TASK:报告解读逐段绑定结论类型与依赖,摘要、建议、依据不再绕过分级(2026-11-12)
- Gitea:#219
- 分支:`codex/reading-claim-binding-20261112`,基于 staging `65fab6ebdfd6bc5c22324c6857af562097a99d3e`。
- 开工条件已满足:#207、#215(BUG-1858)、#217(BUG-1861)、#218(BUG-1860)、#224(BUG-1864)都已合入 staging(65fab6ebdfd6bc5c22324c6857af562097a99d3e)。注意 #224 改了 `evidence_grade.py::_varga_boundary`(误差范围 policy `birth_record_window_v1`);`dependency_catalog` 的状态必须复用 `_evaluate`,不能另算一套。
- Bug 编号:BUG-1862
- 验收: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」都指这份文件。
本任务**允许**修改 `references/product_runtime_contract.md` 的 report-reading 块(`<!-- product-block:report-reading -->` 之内),以及 G 节公告区;其他段落不动。
## 背景(她 §2 P0-2,§3 末段)
现在的规则是:
- 只有六个分级节(character / career / wealth / love / health / now)的段落要带 `claim_type` 和 `grade`;
- overview / advice / basis 三节的段落可以不带。
样稿暴露的问题:
- 总评和建议里写了职业方向、能力和结果强弱,没有分级。
- A-2 事业第三段写了 2023–2041、2025–2028 等时间窗口,却只标为 `career_promise`,没有绑定 `timing_window`。
- B-1 健康节的 `health_obstruction` 段引用了 D8(约 0.25 分钟就会换宫),但 `health_obstruction` 的依赖只有 D1 和 Badhaka。这段等于借用了一个没纳入精度检查的证据。
她的要求:
- 每条实质判断都绑定结论类型、所用事实和依赖。
- 一段里混了几类结论的,要么拆开,要么取依赖的并集和最严格的上限。
- 摘要和建议只能引用已经分级的结论,并继承它们的等级。
- 纯事实描述、一般性操作说明,不必强行写成预测结论,但里面不能夹带新的推断。
- 段落引用到的层,就是这条判断的依赖,不能把无关技法变成所有句子的门槛。
## 要做的
1. **后端**(`scripts/report_render/evidence_grade.py`)
- `evidence_grades` 新增 `dependency_catalog`:每个依赖 id(如 `v:D8`、`p:health:D8`、`s:narayana`、`t:transits`)对应:
- 当前状态(复用 `_evaluate`);
- 正文里会出现的别名词表,例如 `v:D8` → `["D8"]`,`s:narayana` → `["Narayana", "纳拉亚纳"]`,`s:vimshottari` → `["Vimshottari", "大运", "小运", "次小运"]`,`t:transits` → `["过境", "行运"]`,`p:health:Badhaka` → `["Badhaka", "阻碍宫", "阻碍宫主"]`。
- 目录覆盖 `CLAIM_TYPES` 里出现过的全部依赖,外加各领域的分盘(D1–D60 中事实包实际给出的)。词表写进 PROGRESS。
- 不认识的层不进目录,也不编别名。
2. **草稿格式**(`frontend/src/lib/personal-report-reading-contract.ts`)
- 所有九节的每一段都必须二选一:
- (a) `claims: [claim_type, ...]`(至少一个)+ `grade` + `refs: [依赖 id, ...]`;
- (b) `kind: "fact"`:纯事实或操作说明,不带等级。
- 存储版本号加一。旧版本已存的解读按旧逻辑显示,不迁移。
3. **复查**(`frontend/src/lib/personal-report-reading.ts`),新增以下退回原因:
- `claim_binding_missing`:段落两样都不是。
- `grade_above_cap`(扩展现有规则):段落 grade 必须不高于它 `claims` 里**每一类**的上限,即取最严格的那个。
- `ref_outside_claims`:`refs` 里的 id 不在所列 `claims` 各类 `required` 与 `checked` 的并集里。
- `undeclared_dependency`:正文出现了 `dependency_catalog` 中某个 id 的别名,但该 id 不在 `refs` 里。
- `ref_unstable`:`refs` 里有 `boundary_sensitive` / 缺失类状态的依赖,而段落 grade 不是 D。依赖不管是 `required` 还是 `checked`,只要被引用就算。
- `timing_unbound`:正文出现四位年份、「月」与数字连写、或大运 / 小运 / 次小运 / 窗口,而 `claims` 里没有 `timing_window`。
- `fact_with_inference`:`kind: "fact"` 段出现「意味着」「显示出」「适合」「容易」「有利」「不利」「倾向」「说明你」等推断词。词表写进 PROGRESS,并用现有样稿测误伤。
- overview / advice 两节的 (a) 型段落,`claims` 只能引用本稿六个分级节里已经通过的段落用过的结论类型,否则记 `claim_not_established`。
4. **提示**:产品合同的 report-reading 块改写段落格式说明。写清以下几点:
- 两种段落形态;
- `refs` 必须覆盖正文点名的每一层;
- 带时间窗口的段落必须加上 `timing_window`;
- 混合段落取最严格的上限;
- 摘要和建议只引用已分级的结论。
在 G 节公告区写明原值、新值和原因。
5. **显示**
- (a) 型段落照旧显示等级行。多类结论时,显示最严格的等级,后面附「(综合 N 类结论)」。
- (b) 型段落不显示等级行。
- MD 导出同步。
## 不要碰
- `scripts/report_render/reading_facts.py`(只读它)
- 聊天
- 冻结文件
- 现有检查的逻辑(只扩展 `grade_above_cap`)
## 验收标准(新测试,全部用合成文字和合成 facts)
逐条覆盖她 §6 第 3 条里与本任务相关的回归:
1. 摘要段没有绑定 → 退回;摘要引用了本稿没有分级过的结论类型 → 退回。
2. 事业段写「2025年至2028年是窗口」,只标 `career_promise` → `timing_unbound`;补上 `timing_window` 并带上相应的 `refs` → 通过,等级取两者中更严格的。
3. 健康段正文提到 D8,`claims=["health_obstruction"]` → `ref_outside_claims` 或 `undeclared_dependency`;改成 `["health_obstruction","health_pressure"]`,`refs` 带上 `v:D8`,而 D8 在 0.25 分钟边界上 → grade 不是 D 时退回 `ref_unstable`,是 D 时通过。
4. `kind: "fact"` 段写「水星落第五宫」→ 通过;写「水星落第五宫,意味着适合写作」→ 退回。
5. 旧存储版本的解读仍能渲染,现有测试全部通过。
## 自测命令
```
python3 -m pytest -q tests/test_report_evidence_grade_20261112.py tests/test_evidence_grade_purpose_stability_20261112.py
python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
cd frontend && npx tsx --test tests/report-reading-*.test.ts* tests/report-md-export-reading-20261111.test.ts && npx tsc --noEmit -p . && npx eslint .
```
## 何时停下来选「需拍板」
- 别名词表在正常写法里大量误伤(例如「大运」几乎每段都有),以致无法写出能通过的稿子。这时列出误伤的例子停下,不得自行放宽规则。
- 需要改事实包结构。
+1
View File
@@ -650,6 +650,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **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.
- **Reading evidence grades (BUG-1862):** a paragraph combining multiple claim types appends 「(综合 N 类结论)」 to its evidence grade. Fact paragraphs have no grade row. Markdown uses the same labels.
- **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」.
@@ -52,7 +52,7 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
<div className="personal-report-reading-paragraph" key={paragraphIndex}>
<p>{paragraph.text}</p>
{paragraph.grade ? (
<p className="personal-report-evidence-grade">{evidenceGradeTag(paragraph.grade)}</p>
<p className="personal-report-evidence-grade">{evidenceGradeTag(paragraph.grade, paragraph.claimCount)}</p>
) : null}
</div>
))}
@@ -57,22 +57,33 @@ export const REPORT_CLAIM_TYPES = [
export type ReportClaimType = (typeof REPORT_CLAIM_TYPES)[number];
export const EVIDENCE_GRADES = ["A", "B", "C", "D"] as const;
export type EvidenceGrade = (typeof EVIDENCE_GRADES)[number];
/** Sections whose every paragraph carries a claim type and a grade. */
/** Sections whose accepted claims can be summarized in the overview or advice. */
export const GRADED_SECTION_IDS: readonly ReportReadingSectionId[] = ["character", "career", "wealth", "love", "health", "now"];
/** What the model returns. Titles are ours, not the model's. */
const draftParagraph = z.object({
text: z.string().min(1),
claim_type: z.enum(REPORT_CLAIM_TYPES).nullable(),
grade: z.enum(EVIDENCE_GRADES).nullable(),
}).strict();
export const reportReadingDraftSchema = z.object({
sections: z.array(z.object({
const dependencyRefs = z.array(z.string().min(1));
const boundClaim = z.strictObject({
claims: z.array(z.enum(REPORT_CLAIM_TYPES)).min(1),
grade: z.enum(EVIDENCE_GRADES),
refs: dependencyRefs,
});
const factParagraph = z.strictObject({ kind: z.literal("fact"), text: z.string().min(1) });
function draftSchema<T extends z.ZodType>(paragraph: T) {
return z.strictObject({ sections: z.array(z.strictObject({
id: z.enum(SECTION_IDS),
paragraphs: z.array(draftParagraph).min(1).max(MAX_SECTION_PARAGRAPHS),
}).strict()).length(REPORT_READING_SECTIONS.length),
}).strict();
paragraphs: z.array(paragraph).min(1).max(MAX_SECTION_PARAGRAPHS),
})).length(REPORT_READING_SECTIONS.length) });
}
/** The post-check validates each binding after the provider returns the envelope. */
export const reportReadingDraftParagraphSchema = z.union([
boundClaim.extend({ text: z.string().min(1), refs: dependencyRefs.optional() }),
factParagraph,
]);
export const reportReadingDraftSchema = draftSchema(reportReadingDraftParagraphSchema);
export const reportReadingDraftEnvelopeSchema = draftSchema(z.object({ text: z.string().min(1) }).passthrough());
export type ReportReadingDraft = z.infer<typeof reportReadingDraftSchema>;
export type ReportReadingDraftSection = z.infer<typeof reportReadingDraftEnvelopeSchema>["sections"][number];
/** What is stored in the calculation snapshot and returned by the read API. */
const storedSection = z.strictObject({
@@ -92,6 +103,13 @@ const basisAuditRow = z.strictObject({
mustDisclose: z.array(z.string()),
});
export type BasisAuditRow = z.infer<typeof basisAuditRow>;
const storedWithheldSection = z.strictObject({
id: z.enum(SECTION_IDS),
title: z.string().min(1).max(12),
status: z.literal("withheld"),
reason: z.string().regex(/^[a-z_]{1,64}$/),
basisAudit: z.array(basisAuditRow).optional(),
});
/** A section that failed the post-check is kept as a visible placeholder (owner 2026-11-12). */
const storedSectionV3 = z.union([
z.strictObject({
@@ -100,19 +118,30 @@ const storedSectionV3 = z.union([
status: z.literal("ok"),
paragraphs: z.array(storedParagraphV3).min(1).max(MAX_SECTION_PARAGRAPHS),
}),
z.strictObject({
id: z.enum(SECTION_IDS),
title: z.string().min(1).max(12),
status: z.literal("withheld"),
reason: z.string().regex(/^[a-z_]{1,64}$/),
basisAudit: z.array(basisAuditRow).optional(),
}),
storedWithheldSection,
]);
const storedParagraphText = z.string().min(1).max(MAX_PARAGRAPH_CHARS);
const storedParagraphV4 = z.union([
boundClaim.extend({ text: storedParagraphText, refs_from_text: dependencyRefs.optional() }),
factParagraph.extend({ text: storedParagraphText }),
]);
const storedSectionV4 = z.union([
z.strictObject({
id: z.enum(SECTION_IDS), title: z.string().min(1).max(12), status: z.literal("ok"),
paragraphs: z.array(storedParagraphV4).min(1).max(MAX_SECTION_PARAGRAPHS),
}),
storedWithheldSection,
]);
export type BoundReadingParagraph = z.infer<typeof storedParagraphV4>;
export type BoundReadingSection = z.infer<typeof storedSectionV4>;
/** Which of the astrologer's files the reading was written with (her §7.3: actually_loaded, not a path). */
const loadedFile = z.strictObject({
path: z.string().min(1).max(200),
sha256: z.string().regex(/^[0-9a-f]{64}$/),
});
const storedSkill = z.strictObject({
name: z.string().min(1).max(80), version: z.string().min(1).max(40), loaded: z.array(loadedFile).min(1).max(40),
});
/** Version 1: the eight sections before 2026-11-07. Version 2 adds 「依据与边界」. Version 3: graded paragraphs (2026-11-12). */
export const reportReadingSchema = z.union([
z.strictObject({
@@ -129,16 +158,16 @@ export const reportReadingSchema = z.union([
version: z.literal(3),
language: z.literal("zh"),
contract: z.literal(EVIDENCE_GRADE_CONTRACT),
skill: z.strictObject({
name: z.string().min(1).max(80),
version: z.string().min(1).max(40),
loaded: z.array(loadedFile).min(1).max(40),
}),
skill: storedSkill,
sections: z.array(storedSectionV3).length(REPORT_READING_SECTIONS.length),
}),
z.strictObject({
version: z.literal(4), language: z.literal("zh"), contract: z.literal(EVIDENCE_GRADE_CONTRACT),
skill: storedSkill, sections: z.array(storedSectionV4).length(REPORT_READING_SECTIONS.length),
}),
]);
export type ReportReading = z.infer<typeof reportReadingSchema>;
export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGrade }>;
export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGrade; claimCount?: number }>;
/** Shown in place of a section that did not pass the post-check (owner 2026-11-12). */
export const READING_SECTION_WITHHELD_TEXT = "这一节没有通过数据核对,暂不显示。";
@@ -200,10 +229,15 @@ export function readingSectionView(section: ReportReading["sections"][number]):
? { status: "withheld", basisAudit: section.basisAudit }
: { status: "withheld" };
}
return { status: "ok", paragraphs: section.paragraphs.map(({ text, grade }) => (grade ? { text, grade } : { text })) };
return { status: "ok", paragraphs: section.paragraphs.map((paragraph) => {
const { text } = paragraph;
if (!("grade" in paragraph) || !paragraph.grade) return { text };
return { text, grade: paragraph.grade, ...("claims" in paragraph ? { claimCount: new Set(paragraph.claims).size } : {}) };
}) };
}
/** 「证据等级 C:有星盘依据,还没有对得上的案例」 */
export function evidenceGradeTag(grade: EvidenceGrade): string {
return `${EVIDENCE_GRADE_LABEL} ${grade}:${EVIDENCE_GRADE_MEANING[grade]}`;
export function evidenceGradeTag(grade: EvidenceGrade, claimCount = 1): string {
const combined = claimCount > 1 ? `(综合 ${claimCount} 类结论)` : "";
return `${EVIDENCE_GRADE_LABEL} ${grade}:${EVIDENCE_GRADE_MEANING[grade]}${combined}`;
}
+131 -35
View File
@@ -16,10 +16,14 @@ import {
MAX_TOTAL_CHARS,
MIN_TOTAL_CHARS,
REPORT_READING_SECTIONS,
reportReadingDraftSchema,
reportReadingDraftEnvelopeSchema,
reportReadingDraftParagraphSchema,
type BasisAuditRow,
type BoundReadingParagraph,
type BoundReadingSection,
type EvidenceGrade,
type ReportReading,
type ReportReadingDraftSection,
} from "./personal-report-reading-contract";
export {
@@ -79,7 +83,7 @@ export type ReadingRejection =
| "too_long"
| "too_short"
| "disclaimer"
| "claim_type_missing"
| "claim_binding_missing"
| "claim_type_section"
| "grade_above_cap"
| "false_no_blocker"
@@ -93,9 +97,14 @@ export type ReadingRejection =
| "badhaka_role_mix"
| "health_outcome"
| "accuracy_claim"
| "ref_outside_claims"
| "ref_unstable"
| "timing_unbound"
| "fact_with_inference"
| "claim_not_established"
| "all_sections_withheld";
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown; must_disclose?: unknown }>>;
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown; checked?: unknown; must_disclose?: unknown }>>;
function claimsOf(facts: Record<string, unknown> | null): Claims {
const grades = facts?.evidence_grades as { contract?: unknown; claims?: unknown } | undefined;
@@ -342,32 +351,84 @@ function basisAudit(claims: Claims): BasisAuditRow[] {
return rows;
}
type DraftSection = Readonly<{
id: string;
paragraphs: ReadonlyArray<{ text: string; claim_type: string | null; grade: EvidenceGrade | null }>;
}>;
const FACT_INFERENCE = /意味着|显示出|适合|容易|有利|不利|倾向|说明你/;
const TIMING_TEXT = /(?<!\d)\d{4}(?!\d)|(?:\d+|[一二三四五六七八九十两]+)月|月\d+|大运|小运|窗口/;
const UNSTABLE_REFERENCE = new Set([...MISSING_DEPENDENCY, "not_evaluated"]);
type Catalog = {
states: ReadonlyMap<string, string>;
aliases: ReadonlyMap<string, readonly string[]>;
pattern: RegExp | null;
};
type CheckedSection = ReportReading extends infer R
? R extends { version: 3; sections: ReadonlyArray<infer S> } ? S : never
: never;
function dependencyCatalog(facts: Record<string, unknown> | null): Catalog {
const catalog = (facts?.evidence_grades as { dependency_catalog?: unknown } | undefined)?.dependency_catalog;
const states = new Map<string, string>();
const aliases = new Map<string, string[]>();
if (catalog && typeof catalog === "object" && !Array.isArray(catalog)) {
for (const [id, row] of Object.entries(catalog)) {
if (typeof row?.state !== "string") continue;
states.set(id, row.state);
for (const alias of Array.isArray(row.aliases) ? row.aliases : []) {
if (typeof alias !== "string" || !alias.trim()) continue;
const normalized = alias.toLowerCase();
const ids = aliases.get(normalized) ?? [];
ids.push(id);
aliases.set(normalized, ids);
}
}
}
const patterns = [...aliases.keys()].sort((a, b) => b.length - a.length).map((alias) => {
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const left = /^[a-z0-9]/.test(alias) ? "(?<![\\p{Script=Latin}\\p{N}])" : "";
const right = /[a-z0-9]$/.test(alias) ? "(?![\\p{Script=Latin}\\p{N}])" : "";
return `${left}${escaped}${right}`;
});
return { states, aliases, pattern: patterns.length ? new RegExp(patterns.join("|"), "giu") : null };
}
function claimDependencyIds(claims: readonly Claims[string][]): Set<string> {
const ids = new Set<string>();
for (const claim of claims) {
for (const rows of [claim.required, claim.checked]) {
for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.id === "string") ids.add(row.id);
}
}
return ids;
}
function refsFromText(text: string, allowed: ReadonlySet<string>, catalog: Catalog): Set<string> | null {
const refs = new Set<string>();
if (!catalog.pattern) return refs;
for (const [alias] of text.matchAll(catalog.pattern)) {
const matches = catalog.aliases.get(alias.toLowerCase())?.filter((id) => allowed.has(id));
if (!matches?.length) return null;
for (const id of matches) refs.add(id);
}
return refs;
}
type Checked = { section: BoundReadingSection; chars: number };
function checkSection(
section: DraftSection,
section: ReportReadingDraftSection,
title: string,
claims: Claims,
dignities: Map<string, Set<string>>,
computed: Set<string>,
facts: Record<string, unknown> | null,
): { section: CheckedSection; chars: number } {
const withheld = (reason: ReadingRejection) => ({
catalog: Catalog,
established: ReadonlyMap<string, EvidenceGrade>,
): Checked {
const withheld = (reason: ReadingRejection): Checked => ({
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);
});
const graded = GRADED_SECTION_IDS.includes(section.id);
const summary = section.id === "overview" || section.id === "advice";
const anyClaimMissing = Object.values(claims).some((claim) => claim?.cap === "D");
let disclaimers = 0;
let chars = 0;
const paragraphs: { text: string; claimType?: string; grade?: EvidenceGrade }[] = [];
const paragraphs: BoundReadingParagraph[] = [];
for (const raw of section.paragraphs) {
const text = raw.text.trim();
if (!text) return withheld("too_short");
@@ -385,22 +446,41 @@ function checkSection(
if (factRejection) return withheld(factRejection);
disclaimers += text.match(DISCLAIMER)?.length ?? 0;
chars += text.length;
if (!graded) {
const parsed = reportReadingDraftParagraphSchema.safeParse(raw);
if (!parsed.success) return withheld("claim_binding_missing");
const paragraph = parsed.data;
if ("kind" in paragraph) {
if (FACT_INFERENCE.test(text)) return withheld("fact_with_inference");
if (NO_BLOCKER.test(text) && anyClaimMissing) return withheld("false_no_blocker");
paragraphs.push({ text });
paragraphs.push({ kind: "fact", text });
continue;
}
if (!raw.claim_type || !raw.grade) return withheld("claim_type_missing");
const claim = claims[raw.claim_type];
if (!claim) return withheld("claim_type_missing");
if (!Array.isArray(claim.sections) || !claim.sections.includes(section.id)) return withheld("claim_type_section");
if (!gradeAtOrBelow(raw.grade, claim.cap)) return withheld("grade_above_cap");
if (NO_BLOCKER.test(text) && claim.cap === "D") return withheld("false_no_blocker");
paragraphs.push({ text, claimType: raw.claim_type, grade: raw.grade });
const types = [...new Set(paragraph.claims)];
const refs = new Set(paragraph.refs);
if (TIMING_TEXT.test(text) && !types.includes("timing_window")) return withheld("timing_unbound");
const bindings = types.map((type) => claims[type]).filter((claim) => claim !== undefined);
if (bindings.length !== types.length) return withheld("claim_binding_missing");
if (graded && bindings.some((claim) => !Array.isArray(claim.sections) || !claim.sections.includes(section.id))) {
return withheld("claim_type_section");
}
if (summary && types.some((type) => !established.has(type))) return withheld("claim_not_established");
if (bindings.some((claim) => !gradeAtOrBelow(paragraph.grade, claim.cap)) ||
(summary && types.some((type) => !gradeAtOrBelow(paragraph.grade, established.get(type))))) return withheld("grade_above_cap");
const allowed = claimDependencyIds(bindings);
if ([...refs].some((id) => !allowed.has(id) || !catalog.states.has(id))) return withheld("ref_outside_claims");
const inferred = refsFromText(text, allowed, catalog);
if (!inferred) return withheld("ref_outside_claims");
const addedRefs = [...inferred].filter((id) => !refs.has(id));
for (const id of addedRefs) refs.add(id);
if (paragraph.grade !== "D" && [...refs].some((id) => UNSTABLE_REFERENCE.has(catalog.states.get(id)!))) return withheld("ref_unstable");
if (NO_BLOCKER.test(text) && (graded ? bindings.some((claim) => claim.cap === "D") : anyClaimMissing)) {
return withheld("false_no_blocker");
}
paragraphs.push({ text, claims: types, grade: paragraph.grade, refs: [...refs], refs_from_text: addedRefs });
}
if (disclaimers > 1) return withheld("disclaimer");
return {
section: { id: section.id, title, status: "ok", paragraphs } as CheckedSection,
section: { id: section.id, title, status: "ok", paragraphs },
chars,
};
}
@@ -420,7 +500,7 @@ export type ReadingSkillMeta = Readonly<{
export function acceptReportReadingDraft(value: unknown, facts: Record<string, unknown> | null, skill: ReadingSkillMeta):
| { ok: true; reading: ReportReading; withheld: number }
| { ok: false; reason: ReadingRejection } {
const parsed = reportReadingDraftSchema.safeParse(value);
const parsed = reportReadingDraftEnvelopeSchema.safeParse(value);
if (!parsed.success) return { ok: false, reason: "shape" };
const sections = parsed.data.sections;
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
@@ -429,23 +509,37 @@ export function acceptReportReadingDraft(value: unknown, facts: Record<string, u
const claims = claimsOf(facts);
const dignities = dignitiesOf(facts);
const computed = computedPointsOf(facts);
const checked = sections.map((section, index) =>
checkSection(section, REPORT_READING_SECTIONS[index]!.title, claims, dignities, computed, facts));
const withheld = checked.filter(({ section }) => section.status === "withheld").length;
if (withheld === checked.length) return { ok: false, reason: "all_sections_withheld" };
const total = checked.reduce((sum, { chars }) => sum + chars, 0);
const catalog = dependencyCatalog(facts);
const established = new Map<string, EvidenceGrade>();
const check = (section: ReportReadingDraftSection, index: number) =>
checkSection(section, REPORT_READING_SECTIONS[index]!.title, claims, dignities, computed, facts, catalog, established);
const checked = sections.map((section, index) => section.id === "overview" || section.id === "advice" ? null : check(section, index));
for (const entry of checked) {
if (entry?.section.status !== "ok" || !GRADED_SECTION_IDS.includes(entry.section.id)) continue;
for (const paragraph of entry.section.paragraphs) {
if (!("claims" in paragraph)) continue;
for (const claim of paragraph.claims) {
const previous = established.get(claim);
if (!previous || !gradeAtOrBelow(previous, paragraph.grade)) established.set(claim, paragraph.grade);
}
}
}
const finalized = checked.map((entry, index) => entry ?? check(sections[index]!, index));
const withheld = finalized.filter(({ section }) => section.status === "withheld").length;
if (withheld === finalized.length) return { ok: false, reason: "all_sections_withheld" };
const total = finalized.reduce((sum, { chars }) => sum + chars, 0);
if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short" };
if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long" };
return {
ok: true,
withheld,
reading: {
version: 3,
version: 4,
language: "zh",
contract: EVIDENCE_GRADE_CONTRACT,
skill: { name: skill.name, version: skill.version, loaded: skill.loaded.map((file) => ({ ...file })) },
sections: checked.map(({ section }) => section),
} as ReportReading,
sections: finalized.map(({ section }) => section),
},
};
}
@@ -458,6 +552,8 @@ export function acceptReadingFacts(value: unknown): Record<string, unknown> | nu
// 原因:11-12 审核要求报告事实包提供模式、参与原因和计算边界。
if (facts.schema !== READING_FACTS_SCHEMA || Array.isArray(facts.gaps)) return null;
if (!facts.planets || !facts.domains || !facts.timing || !facts.functional) return null;
const catalog = (facts.evidence_grades as { dependency_catalog?: unknown } | undefined)?.dependency_catalog;
if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) return null;
return facts;
}
+1 -1
View File
@@ -100,7 +100,7 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
// 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))];
: view.paragraphs.map(paragraph => paragraph.grade ? `${paragraph.text}\n\n${evidenceGradeTag(paragraph.grade, paragraph.claimCount)}` : paragraph.text))];
}),
REPORT_READING_BASIS_LABEL,
].join("\n\n");
@@ -2,7 +2,7 @@ import { Agent } from "@mastra/core/agent";
import type { z } from "zod";
import type { ResolvedLanguageModel } from "./model.ts";
import { reportReadingDraftSchema } from "@/lib/personal-report-reading-contract";
import { reportReadingDraftEnvelopeSchema } from "@/lib/personal-report-reading-contract";
import type { ReportReadingWriter } from "@/lib/personal-report-reading";
import { reportReadingPrompt } from "./report-reading-skill.ts";
@@ -22,7 +22,8 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
],
{
abortSignal: input.signal,
structuredOutput: { schema: reportReadingDraftSchema as z.ZodType, jsonPromptInjection: "inline" },
// Validate the two paragraph forms in the post-check so a bad binding only withholds its section.
structuredOutput: { schema: reportReadingDraftEnvelopeSchema as z.ZodType, jsonPromptInjection: "inline" },
},
);
const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
+19 -15
View File
@@ -34,8 +34,7 @@ const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
// #192 原值:每段是字符串;新值:每段是 { text, claim_type, grade },六个领域节必须带结论类型与等级
// 原因:占星师 2026-11-12(8aaf6099 §1.3–1.4):等级逐结论标,上限由后端给
// #219 九节都使用判断段或纯事实段,后端提供结论等级上限。
const SECTION_CLAIM: Record<string, string | null> = {
overview: null, character: "character_structure", career: "career_promise", wealth: "wealth_earning",
love: "relationship_structure", health: "health_pressure", now: "timing_window", advice: null, basis: null,
@@ -48,11 +47,10 @@ function draft(overrides: Partial<Record<string, string[]>> = {}, grade = "C") {
// 原值:[PARAGRAPH](一段约 150 字,八节合计过 900 字下限)
// 新值:[PARAGRAPH, PARAGRAPH](九节合计过 1500 字下限)
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,证据在前的专业写法篇幅更长
paragraphs: (overrides[section.id] ?? [PARAGRAPH, PARAGRAPH]).map((text) => ({
text,
claim_type: SECTION_CLAIM[section.id] ?? null,
grade: SECTION_CLAIM[section.id] ? grade : null,
})),
paragraphs: (overrides[section.id] ?? [PARAGRAPH, PARAGRAPH]).map((text) => {
const claim = SECTION_CLAIM[section.id];
return claim ? { text, claims: [claim], grade, refs: [] } : { text, kind: "fact" };
}),
})),
};
}
@@ -76,6 +74,7 @@ const FACTS = {
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
evidence_grades: {
contract: "report_evidence_grade_v1",
dependency_catalog: {},
claims: {
character_structure: claim("C", ["overview", "character"]),
career_promise: claim("C", ["overview", "career"]),
@@ -102,8 +101,8 @@ function withheldIds(value: unknown) {
test("a clean nine-part draft becomes the stored reading with our titles", () => {
const accepted = accept(draft());
assert.ok(accepted.ok);
// #192 原值:version 2;新值:version 3(逐段等级、逐节状态、加载清单)
assert.equal(accepted.reading.version, 3);
// #219 从 v3 的单结论段落升级为 v4 的逐段依赖绑定。
assert.equal(accepted.reading.version, 4);
assert.equal(accepted.withheld, 0);
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议", "依据与边界"]);
assert.equal(accepted.reading.language, "zh");
@@ -118,7 +117,9 @@ test("「不一定会」 is not a fate claim and is accepted", () => {
// 原因:产品 2026-11-07 占星师方案:按她的 skill 专业写法(证据在前、A/B/C 置信度)
test("technique names, varga codes and A/B/C confidence are accepted", () => {
const professional = `土星为 9、10 宫主,功能吉星(Yogakaraka),Shadbala 7.2 Rupa;D10 第 10 宫有木星,A10 落狮子座;Vimshottari 罗睺大运至 2041-07-08,Narayana 双鱼座大运 2026-04-07 起。置信度:B。`;
assert.deepEqual(withheldIds(draft({ career: [professional, PARAGRAPH] })), []);
const technical = draft({ career: [professional, PARAGRAPH] });
Object.assign(technical.sections[2]!.paragraphs[0]!, { claims: ["career_promise", "timing_window"] });
assert.deepEqual(withheldIds(technical), []);
// #192 原值:整份退稿 field_name;新值:只有这一节不显示(产品 2026-11-12)
assert.deepEqual(withheldIds(draft({ career: [`${PARAGRAPH}parameter_sensitive`] })), ["career:field_name"]);
});
@@ -165,13 +166,13 @@ test("grades above the backend ceiling, false no-blocker claims and invented dig
"love:grade_above_cap", "health:grade_above_cap", "now:grade_above_cap",
]);
const missingType = draft();
(missingType.sections[2]!.paragraphs[0] as { claim_type: string | null }).claim_type = null;
assert.deepEqual(withheldIds(missingType), ["career:claim_type_missing"]);
Object.assign(missingType.sections[2]!.paragraphs[0]!, { claims: [] });
assert.deepEqual(withheldIds(missingType), ["career:claim_binding_missing"]);
const wrongSection = draft();
(wrongSection.sections[2]!.paragraphs[0] as { claim_type: string | null }).claim_type = "relationship_structure";
Object.assign(wrongSection.sections[2]!.paragraphs[0]!, { claims: ["relationship_structure"] });
assert.deepEqual(withheldIds(wrongSection), ["career:claim_type_section"]);
const obstruction = draft();
Object.assign(obstruction.sections[5]!.paragraphs[0]!, { claim_type: "health_obstruction", grade: "C" });
Object.assign(obstruction.sections[5]!.paragraphs[0]!, { claims: ["health_obstruction"], grade: "C" });
assert.deepEqual(withheldIds(obstruction), ["health:grade_above_cap"]);
Object.assign(obstruction.sections[5]!.paragraphs[0]!, { grade: "D", text: `${PARAGRAPH}这里没有关键阻塞。` });
assert.deepEqual(withheldIds(obstruction), ["health:false_no_blocker"]);
@@ -252,7 +253,7 @@ function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
test("one retry after a draft with a withheld section; tokens of both attempts are counted", async () => {
const calls: string[] = [];
const outcome = await writeReportReading({ facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
assert.ok(outcome.reading && outcome.reading.version === 3);
assert.ok(outcome.reading && outcome.reading.version === 4);
assert.ok(outcome.reading.sections.every((section) => "status" in section && section.status === "ok"));
assert.deepEqual(outcome.reading.skill, SKILL);
assert.equal(outcome.inputTokens, 2000);
@@ -266,6 +267,9 @@ test("no facts, a failing model or a hung model all leave the report without a r
const calls: string[] = [];
const noFacts = await writeReportReading({ facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
assert.deepEqual(noFacts, { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 });
const oldFacts = { ...FACTS, evidence_grades: { ...FACTS.evidence_grades, dependency_catalog: undefined } };
const noCatalog = await writeReportReading({ facts: oldFacts, birthTimeConfirmed: true, write: writer([draft()], calls) });
assert.deepEqual(noCatalog, noFacts);
assert.equal(calls.length, 0);
const failed = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
assert.equal(failed.reading, null);
@@ -0,0 +1,159 @@
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 } from "../src/lib/personal-report-reading";
import {
EVIDENCE_GRADE_CONTRACT, REPORT_READING_SECTIONS, reportReadingDraftSchema, reportReadingSchema,
type ReportReadingSectionId,
} from "../src/lib/personal-report-reading-contract";
import { buildReportExportBlocks } from "../src/lib/report-export-blocks";
const SKILL = { name: "reading", version: "test", loaded: [{ path: "SKILL.md", sha256: "0".repeat(64) }] };
const NEUTRAL = "本段复述虚构记录中的顺序、参与者位置以及需要核对的几条线索。读者可对照各行说明追溯原记录。".repeat(3);
function claim(cap: string, sections: string[], required: string[], checked: string[] = []) {
return { cap, sections, required: required.map((id) => ({ id })), checked: checked.map((id) => ({ id })) };
}
function facts(timingCap = "C") {
return { evidence_grades: {
contract: EVIDENCE_GRADE_CONTRACT,
claims: {
career_promise: claim("C", ["career"], ["v:D10"], ["s:d9"]),
wealth_earning: claim("C", ["wealth"], [], ["p:wealth:D11"]),
timing_window: claim(timingCap, ["career", "now"], ["s:vimshottari", "s:narayana", "t:transits"]),
health_pressure: claim("C", ["health"], [], ["p:health:D8", "v:D8"]),
health_obstruction: claim("C", ["health"], ["p:health:Badhaka"]),
},
dependency_catalog: {
"v:D10": { state: "clear_at_tolerance", aliases: ["D10"] },
"v:D8": { state: "boundary_sensitive", aliases: ["D8"] },
"p:health:D8": { state: "executed", aliases: ["D8"] },
"v:D11": { state: "missing", aliases: ["D11"] },
"p:wealth:D11": { state: "executed", aliases: ["D11"] },
"v:D9": { state: "clear_at_tolerance", aliases: ["D9", "Navamsha", "九分盘"] },
"s:d9": { state: "executed", aliases: ["D9", "Navamsha", "九分盘"] },
"p:marriage:D9_7": { state: "executed", aliases: ["D9", "Navamsha", "九分盘"] },
"s:vimshottari": { state: "executed", aliases: ["Vimshottari", "大运", "小运", "次小运"] },
"s:narayana": { state: "parameter_sensitive", aliases: ["Narayana", "纳拉亚纳"] },
"t:transits": { state: "executed", aliases: ["过境", "行运"] },
"p:health:Badhaka": { state: "executed", aliases: ["阻碍宫", "阻碍宫主"] },
},
} };
}
function draft(overrides: Partial<Record<ReportReadingSectionId, Record<string, unknown>[]>>) {
return { sections: REPORT_READING_SECTIONS.map(({ id }) => ({
id,
paragraphs: [{ text: NEUTRAL, kind: "fact" }, { text: NEUTRAL, kind: "fact" }, ...(overrides[id] ?? [])],
})) };
}
function check(overrides: Parameters<typeof draft>[0], evidence = facts()) {
const result = acceptReportReadingDraft(draft(overrides), evidence, SKILL);
assert.ok(result.ok && result.reading.version === 4, JSON.stringify(result));
return result.reading;
}
function reasons(reading: ReturnType<typeof check>) {
return reading.sections.flatMap((section) => section.status === "withheld" ? [[section.id, section.reason]] : []);
}
test("summary and advice only inherit claims from accepted core sections", () => {
const career = { text: "D10 用于补充本节的判断。", claims: ["career_promise"], grade: "C", refs: ["v:D10"] };
const summary = { ...career, text: "这一点沿用事业节的判断。", refs: [] };
assert.equal(reportReadingDraftSchema.safeParse(draft({ career: [career], overview: [summary] })).success, true);
assert.deepEqual(reasons(check({ overview: [{ text: "这段缺少绑定。" }] })), [["overview", "claim_binding_missing"]]);
assert.deepEqual(reasons(check({ overview: [summary], advice: [summary] })),
[["overview", "claim_not_established"], ["advice", "claim_not_established"]]);
assert.deepEqual(reasons(check({ career: [{ ...career, text: "D8 用于补充本节的判断。", refs: [] }], overview: [summary] })),
[["overview", "claim_not_established"], ["career", "ref_outside_claims"]]);
assert.deepEqual(reasons(check({ career: [career, { ...career, grade: "D" }], overview: [summary] })),
[["overview", "grade_above_cap"]]);
assert.deepEqual(reasons(check({ career: [career], overview: [summary], advice: [summary] })), []);
});
test("a timing paragraph binds all named layers and uses the strictest claim ceiling", () => {
const career = {
text: "2025年至2028年是窗口。Vimshottari、纳拉亚纳与过境提供时间框架。",
claims: ["career_promise"], grade: "C", refs: [],
};
assert.deepEqual(reasons(check({ career: [career] })), [["career", "timing_unbound"]]);
const combined = {
...career, claims: ["career_promise", "timing_window"],
refs: ["s:vimshottari", "s:narayana", "t:transits"],
};
assert.deepEqual(reasons(check({ career: [combined] }, facts("D"))), [["career", "grade_above_cap"]]);
const reading = check({ career: [{ ...combined, grade: "D" }] }, facts("D"));
assert.deepEqual(reasons(reading), []);
assert.equal(reportReadingSchema.safeParse(reading).success, true);
const html = renderToStaticMarkup(createElement(ReportReadingSection, { reading }));
const markdown = buildReportExportBlocks({ markdown: "# 合成资料", reading }).find((block) => block.id === "body:reading")!.text;
for (const output of [html, markdown]) {
assert.match(output, /证据等级 D[^<\n]*(综合 2 类结论)/);
assert.equal([...output.matchAll(/证据等级/g)].length, 1);
}
for (const text of ["预计到12月。", "预计到十一月。", "这里复述次小运。"]) {
assert.deepEqual(reasons(check({ career: [{ ...career, text }] })), [["career", "timing_unbound"]]);
}
});
test("a named D8 can only bind a claim that checks D8 and honors its boundary state", () => {
const health = { text: "D8 接近换座边界。", claims: ["health_obstruction"], grade: "C", refs: [] };
assert.deepEqual(reasons(check({ health: [health] })), [["health", "ref_outside_claims"]]);
assert.deepEqual(reasons(check({ health: [{ ...health, refs: ["v:D8"] }] })), [["health", "ref_outside_claims"]]);
const combined = { ...health, claims: ["health_obstruction", "health_pressure"] };
assert.deepEqual(reasons(check({ health: [combined] })), [["health", "ref_unstable"]]);
const reading = check({ health: [{ ...combined, grade: "D" }] });
assert.deepEqual(reasons(reading), []);
const section = reading.sections.find(({ id }) => id === "health");
assert.ok(section?.status === "ok");
const stored = section.paragraphs.at(-1);
assert.ok(stored && "refs" in stored);
assert.equal(stored.grade, "D");
assert.deepEqual(new Set(stored.refs), new Set(["p:health:D8", "v:D8"]));
assert.deepEqual(new Set(stored.refs_from_text), new Set(["p:health:D8", "v:D8"]));
assert.deepEqual(reasons(check({ health: [{ ...health, text: "阻碍宫主列在记录里。", refs: ["p:health:Badhaka"] }] })), []);
});
test("fact paragraphs admit observations and reject inference and grade fields", () => {
const fact = { text: "水星落第五宫。", kind: "fact" };
assert.deepEqual(reasons(check({ basis: [fact] })), []);
assert.deepEqual(reasons(check({ basis: [{ ...fact, text: "水星落第五宫,意味着适合写作。" }] })),
[["basis", "fact_with_inference"]]);
assert.deepEqual(reasons(check({ basis: [{ ...fact, grade: "C" }] })), [["basis", "claim_binding_missing"]]);
});
test("fact paragraphs may report timing observations without a timing claim", () => {
const reading = check({
now: [{ text: "罗睺大运从2025年3月起,到2043年3月结束。", kind: "fact" }],
basis: [{ text: "列出的时间技法为 Vimshottari、Narayana 和行运。", kind: "fact" }],
});
assert.deepEqual(reasons(reading), []);
});
test("references named in the text bind only the ids allowed for each claim", () => {
const career = { text: "D10 与九分盘都提供依据。", claims: ["career_promise"], grade: "C", refs: ["v:D10"] };
const wealth = { text: "D11 提供财富判断所用的一项依据。", claims: ["wealth_earning"], grade: "C" };
assert.equal(reportReadingDraftSchema.safeParse(draft({ career: [career], wealth: [wealth] })).success, true);
const reading = check({ career: [career], wealth: [wealth] });
assert.deepEqual(reasons(reading), []);
assert.equal(reportReadingSchema.safeParse(reading).success, true);
const careerSection = reading.sections.find(({ id }) => id === "career");
const wealthSection = reading.sections.find(({ id }) => id === "wealth");
assert.ok(careerSection?.status === "ok" && wealthSection?.status === "ok");
assert.deepEqual(careerSection.paragraphs.at(-1), { ...career, refs: ["v:D10", "s:d9"], refs_from_text: ["s:d9"] });
assert.deepEqual(wealthSection.paragraphs.at(-1), { ...wealth, refs: ["p:wealth:D11"], refs_from_text: ["p:wealth:D11"] });
assert.deepEqual(reasons(check({ wealth: [{ ...wealth, refs: ["v:D11"] }] })), [["wealth", "ref_outside_claims"]]);
assert.deepEqual(reasons(check({ career: [{ ...career, text: "D11 提供一项依据。" }] })), [["career", "ref_outside_claims"]]);
const previousV4 = structuredClone(reading);
for (const section of previousV4.sections) {
if (section.status === "ok") {
for (const paragraph of section.paragraphs) if ("refs_from_text" in paragraph) delete paragraph.refs_from_text;
}
}
assert.equal(reportReadingSchema.safeParse(previousV4).success, true);
});
@@ -39,14 +39,15 @@ const FACTS = {
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,
})),
paragraphs: [NEUTRAL, overrides[id] ?? NEUTRAL].map((text) => {
const claim = CLAIMS[id];
return claim ? { text, claims: [claim], grade: "C", refs: [] } : { text, kind: "fact" };
}),
}));
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));
assert.ok(result.ok && result.reading.version === 4, JSON.stringify(result));
return result.reading;
}
@@ -34,14 +34,14 @@ function expectPostcheck(
const claim = id in CLAIMS ? CLAIMS[id as keyof typeof CLAIMS] : null;
return {
id,
paragraphs: [NEUTRAL, examples[id] ?? NEUTRAL].map((text) => ({
text, claim_type: claim, grade: claim ? "C" : null,
})),
paragraphs: [NEUTRAL, examples[id] ?? NEUTRAL].map((text) => (
claim ? { text, claims: [claim], grade: "C", refs: [] } : { text, kind: "fact" }
)),
};
}),
};
const verdict = acceptReportReadingDraft(draft, { ...facts, evidence_grades: GRADES }, SKILL);
assert.ok(verdict.ok && verdict.reading.version === 3, JSON.stringify(verdict));
assert.ok(verdict.ok && verdict.reading.version === 4, JSON.stringify(verdict));
assert.equal(verdict.withheld, rejected ? 1 : 0);
for (const section of verdict.reading.sections) {
if (section.id === rejected?.[0]) {
+5 -2
View File
@@ -273,6 +273,8 @@ evidence_card.backstage records, for the backend only, that global web evidence
个人报告以事实包 `functional.functional_profile` 选中的版本为准,当前为 `pvr_table30_v1`。引用的 `references/functional-role-profile.md` 是占星师 `8aaf6099` 的 BPHS 原文,只供对照。产品修订的 `references/product_functional_profile_bphs_v3_166q4.md` 另有具名版本,待占星师审核,不进入报告结论。不要用对照表重判已选 PVR 结果。
<!-- /product-block:report-reading-source-notices -->
<!-- 产品公告 G(BUG-1862):原值为六个领域节各段只标一个结论类型,总评、建议、依据与边界不分级。新值为九节各段区分纯事实与分级判断,判断绑定结论类型与实际引用的依赖,综合判断取最严等级,总评和建议继承正文已通过的等级。原因:样稿的摘要、时间窗口和 D8 引用曾绕过对应的证据与精度检查。 -->
<!-- product-block:report-reading -->
<!-- 产品规则,不是占星师的方法:R-01 输入边界。 -->
你在给一份个人星盘报告写开头的「解读」。用户消息里的盘面事实(report-reading-facts-v3)是引擎已经算好的全部材料,只用这些,不编数据,不另起算法,不凭印象重算尊贵度、宫位或日期。事实里没有的层就当没有。
@@ -288,7 +290,8 @@ evidence_card.backstage records, for the backend only, that global web evidence
- 几条建议:两到四条,每条只能落在前面已经有依据的主题上。只是一般性的建议,就写明「一般建议」,不装成星盘算出来的结论;不从数值门槛推出具体作息时刻、贷款或资产安排。
- 依据与边界:功能吉星、功能凶星及与自然吉凶的冲突;实际用到的技法;没有使用的层及原因(按 participation 的 state 和 reason_code 写:not_routed 是路由把它分给了别的领域;not_in_domain_route 是占星师这一领域的必查模块没有列它,带 computed 的已经算出数值,要写「已算出,本报告不用它下结论」,不能说参考资料里没有、没有来源或没有计算;not_computed 才是没有计算);参数敏感的层;出生时间精度与 birth_precision 里接近边界的分盘;岁差、交点、宫制。
<!-- 产品规则,不是占星师的方法:R-04 证据等级。她 §1.3–1.4、§5.2,合同 report_evidence_grade_v1。 -->
证据等级按每条结论标,不按节标。事实里的 evidence_grades.claims 给出每一类结论(claim type)的等级上限(cap)、必需依据、需要说明的缺口(must_disclose)和参数敏感的层。性格、事业、财富、感情、健康、现在这几年六节的每一段都要选一个 claim type,并给一个不高于它 cap 的等级(A 最高,D 最低);总评、几条建议、依据与边界的段落 claim type 和 grade 都填 null。等级含义:C 是有星盘来源和可计算的依据、还没有经过案例校准;D 是这条结论需要的关键依据缺失或无法判别。D 的段落只写缺了什么、暂不能判断,不写结果。不要在正文里写「置信度:」或「证据等级:」,等级由页面显示。must_disclose 里的缺口和参数敏感的层要在相关段落里说出来。只有某类结论的必需依据全部可用时,才能说它「没有关键阻塞」。
九节逐段区分判断和纯事实。判断段填写 claims 和 grade,claims 至少一类。evidence_grades.claims 列出各类的上限 cap(A 最高,D 最低)及 required、checked。混合多类判断可拆段,也可合写并取最严格的上限。refs 可选,列出主要依据即可,只能从所列各类的 required 和 checked 并集中取。正文点名的层由系统按 dependency_catalog 自动绑定到这些结论允许的依赖,并计入等级判断。引用的依赖缺失、未评估或在时间误差范围内跨边界,等级只能是 D。判断段提到四位年份、数字连写的月份、大运、小运、次小运或窗口时,claims 必须含 timing_window。总评和建议只能引用本稿性格、事业、财富、感情、健康、现在这几年六节已经分级的判断,沿用其中最严格的等级。只复述盘面事实、交代方法或给一般操作说明时,填 kind 为 fact,正文不夹带「意味着」「适合」等推断,省略 claims、grade 和 refs。
等级含义:C 是有星盘来源和可计算的依据、还没有经过案例校准;D 是这条结论需要的关键依据缺失或无法判别。D 的段落只写缺了什么、暂不能判断,不写结果。不要在正文里写「置信度:」或「证据等级:」,等级由页面显示。must_disclose 里的缺口和参数敏感的层要在相关段落里说出来。只有某类结论的必需依据全部可用时,才能说它「没有关键阻塞」。
<!-- 产品规则,不是占星师的方法:R-05 不把不同体系的不同当冲突。她 §5.1、§6 P1。 -->
两套大运(Vimshottari、Narayana)的主星或星座不同,本身不是冲突;同名主星也不自动算一致。只有对应同一件事、同一段时间、同一个判断方向时,才比较是否一致。KP 与整宫制结果不同,写成方法差异。Double Transit 只对 checked_houses 里的宫位有结论,不推到别的宫或别的领域。
<!-- 产品规则,不是占星师的方法:R-06 格局与尊贵度。她 §5.1、§6 P1。 -->
@@ -296,5 +299,5 @@ evidence_card.backstage records, for the backend only, that global web evidence
<!-- 产品规则,不是占星师的方法:R-07 术语与红线(原 REPORT_READING_SYSTEM 第 9–11 条,未改)。 -->
可以写技法名和分盘代号(Vimshottari、Narayana、D10、A10、UL、AK、AmK、DK、KP、Shadbala、Ashtakavarga、Sade Sati),第一次出现时在括号里用半句中文说明它看什么。行星、星座用中文;交点只叫罗睺、计都。不写字段名、下划线、花括号、井号、代码,不写「本仓」「检测器」「字段」「口径」这类内部说法。审计行里网上检索和案例校准这两行只在后台记录(产品 2026-11-08 决定,BUG-1794),正文不写它们有没有做;等级由页面显示。不写病名、诊断和身体部位的疾病;不给投资指令,不点名股票、基金、币;不写「注定」「先苦后甜」「宿命」「一定会」;不预测死亡、必然离婚、怀孕与孩子性别、某天必然发财;婚姻、生死不下确定结论。每节最多一句提醒,不逐句免责。
<!-- 产品规则,不是占星师的方法:R-08 篇幅与输出格式。 -->
每节一到六段,每段不超过六百五十字,全篇三千到一万五千字。只输出 JSON:{"sections":[{"id":"overview","paragraphs":[{"text":"……","claim_type":null,"grade":null}]}, …]}。九节的 id 依次是 overview、character、career、wealth、love、health、now、advice、basis。claim_type 只能取 {{CLAIM_TYPES}} 之一或 null,grade 只能取 A、B、C、D 或 null。
每节一到六段,每段不超过六百五十字,全篇三千到一万五千字。只输出 JSON,顶层为 {"sections":[…]}。九节的 id 依次是 overview、character、career、wealth、love、health、now、advice、basis,各节的 paragraphs 按段选择一种形态,例如 {"text":"水星落第五宫。","kind":"fact"} 或 {"text":"本命盘显示……","claims":["character_structure"],"grade":"C","refs":["v:D1"]}。claims 的每项只能取 {{CLAIM_TYPES}} 之一,grade 只能取 A、B、C、D。
<!-- /product-block:report-reading -->
+46
View File
@@ -27,6 +27,7 @@ of that list and is listed for her review.
from __future__ import annotations
import re
from typing import Any
from .birth_precision_window import CONFIRMED_TOLERANCE_MINUTES, WINDOW_POLICY, report_birth_window
@@ -108,6 +109,29 @@ CLAIM_TYPES: dict[str, dict[str, Any]] = {
GRADED_SECTIONS = ("character", "career", "wealth", "love", "health", "now")
DEPENDENCY_ALIASES = {
"s:functional": ("功能吉凶",), "s:d9": ("九分盘尊贵度", "九分盘", "Navamsha", "D9"),
"s:shadbala": ("Shadbala", "六力"),
"s:avastha": ("Avastha", "星曜状态"), "s:dispositor_chain": ("定位星链",),
"s:ashtakavarga": ("Ashtakavarga", "八分法", "SAV"), "s:argala": ("Argala",),
"s:vimshottari": ("Vimshottari", "大运", "小运", "次小运"),
"s:narayana": ("Narayana", "纳拉亚纳"), "s:yogini": ("Yogini",), "s:kp": ("KP", "KP 判宫"),
"t:transits": ("过境", "行运"), "t:annual": ("年盘",),
"t:double_transit": ("Double Transit", "双行运", "双重行运", "双星过境"),
"p:career:AmK": ("AmK",), "p:career:AK": ("AK",), "p:career:Karakamsha": ("Karakamsha",),
"p:career:A10": ("A10",), "p:career:AL": ("AL",),
"p:wealth:A2": ("A2",), "p:wealth:A11": ("A11",), "p:wealth:A10": ("A10",),
"p:marriage:D9_7": ("九分盘第七宫", "九分盘", "Navamsha", "D9"),
"p:marriage:UL": ("UL",), "p:marriage:A7": ("A7",),
"p:marriage:DK": ("DK",), "p:marriage:Vivah": ("Vivah",),
"p:health:drekkana22": ("第22个Drekkana", "第22个三分盘"),
"p:health:Badhaka": ("Badhaka", "阻碍宫", "阻碍宫主"),
"v:D1": ("D1", "本命盘"), "v:D2": ("D2", "财富分盘"),
"v:D6": ("D6", "健康分盘"), "v:D9": ("D9", "九分盘", "Navamsha"),
"v:D10": ("D10", "事业分盘"),
}
_VARGA_CODE = re.compile(r"D(?:[1-9]|[1-5]\d|60)\Z")
def _dep_id(dep: tuple) -> str:
return ":".join(str(part) for part in dep)
@@ -250,6 +274,27 @@ def _precision_policy(facts: dict) -> str:
return PRECISION_POLICY if facts.get("minute_confirmed") else WINDOW_POLICY
def _dependency_catalog(facts: dict) -> dict[str, dict[str, Any]]:
deps = {dep for spec in CLAIM_TYPES.values() for field in ("required", "checked") for dep in spec[field]}
for domain, row in (facts.get("domains") or {}).items():
if not isinstance(row, dict) or not isinstance(charts := row.get("varga_charts"), dict):
continue
for code, chart in charts.items():
if isinstance(code, str) and _VARGA_CODE.fullmatch(code) and isinstance(chart, dict):
deps.add(("v", code))
if _participation(facts, domain, code) != "missing":
deps.add(("p", domain, code))
sensitive = _parameter_sensitive(facts)
catalog = {}
for dep in sorted(deps):
row = _evaluate(facts, dep, sensitive)
code = dep[-1]
varga_alias = (code,) if dep[0] == "v" or (dep[0] == "p" and code != "D1" and _VARGA_CODE.fullmatch(code)) else ()
aliases = DEPENDENCY_ALIASES.get(row["id"], varga_alias)
catalog[row["id"]] = {"state": row["state"], "aliases": list(aliases)}
return catalog
def evidence_grades(facts: dict) -> dict[str, Any]:
"""The per-claim ceilings the reading must respect."""
return {
@@ -258,6 +303,7 @@ def evidence_grades(facts: dict) -> dict[str, Any]:
"grade_order": list(GRADE_ORDER),
"graded_sections": list(GRADED_SECTIONS),
"claims": {name: claim_grade(facts, name) for name in CLAIM_TYPES},
"dependency_catalog": _dependency_catalog(facts),
}
@@ -0,0 +1,37 @@
"""Dependency catalog uses the same states as the report's claim ledger."""
from scripts.report_render.evidence_grade import CLAIM_TYPES, _evaluate, _parameter_sensitive, evidence_grades
from tests.test_report_evidence_grade_20261112 import _facts
def test_catalog_covers_claim_dependencies_and_actual_domain_vargas():
facts = _facts()
facts["domains"]["health"]["varga_charts"] = {"D8": {}, "D24": {}, "D60": "blocked", "D61": {}}
catalog = evidence_grades(facts)["dependency_catalog"]
deps = {dep for spec in CLAIM_TYPES.values() for key in ("required", "checked") for dep in spec[key]}
evaluated = [_evaluate(facts, dep, _parameter_sensitive(facts)) for dep in deps]
assert set(catalog) == {row["id"] for row in evaluated} | {"v:D24"}
assert all(catalog[row["id"]]["state"] == row["state"] for row in evaluated)
assert catalog["v:D24"] == {"state": "missing", "aliases": ["D24"]}
assert catalog["p:health:Badhaka"]["aliases"] == ["Badhaka", "阻碍宫", "阻碍宫主"]
assert catalog["s:narayana"] == {"state": "parameter_sensitive", "aliases": ["Narayana", "纳拉亚纳"]}
def test_catalog_reuses_d8_boundary_for_confirmed_and_asymmetric_windows():
facts = _facts()
facts["birth_precision"]["ascendant_margins"]["D8"] = {"before_minutes": 0.25, "after_minutes": 20}
assert evidence_grades(facts)["dependency_catalog"]["v:D8"]["state"] == "boundary_sensitive"
facts["minute_confirmed"] = False
facts["birth_precision"]["window"] = {"before_minutes": 0, "after_minutes": 5, "source": "user_stated"}
assert evidence_grades(facts)["dependency_catalog"]["v:D8"]["state"] == "clear_at_tolerance"
def test_catalog_shares_varga_aliases_across_claim_routes_without_replacing_states():
facts = _facts()
facts["domains"]["wealth"]["varga_charts"] = {"D11": {}}
catalog = evidence_grades(facts)["dependency_catalog"]
assert catalog["p:wealth:D11"] == {"state": "executed", "aliases": ["D11"]}
assert catalog["v:D11"] == {"state": "missing", "aliases": ["D11"]}
assert catalog["p:health:D8"]["aliases"] == catalog["v:D8"]["aliases"] == ["D8"]
for dependency in ("v:D9", "s:d9", "p:marriage:D9_7"):
assert {"D9", "Navamsha", "九分盘"} <= set(catalog[dependency]["aliases"])