Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 1ba0364a7a fix(report): move Badhaka out of her engine into an announced product module (#192, BUG-1853)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m7s
Independent Staging Quality Gate / validate-python (push) Successful in 6m55s
Independent Staging Quality Gate / publish (push) Successful in 2m45s
23e5e6c0 put badhaka_facts into yoga_engine.py and a hook into
jyotish_engine.py. Owner rule 2026-11-07: her skill runs as-is; product
differences live in separate, announced product files. Both engine files are
back to 6f1b9ac3. scripts/report_render/badhaka_facts.py computes the record
from D1 positions (only calls yoga_engine.badhaka_house), marked
product_computation_pending_astrologer_review, and is announced in
product_runtime_contract.md section G. Results are unchanged on both public charts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-12 18:50:31 +08:00
Claude 23e5e6c02e Merge remote-tracking branch 'origin/staging' into claude/report-reading-grade-20261112
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m10s
Independent Staging Quality Gate / validate-python (push) Successful in 6m40s
Independent Staging Quality Gate / publish (push) Canceled after 2m42s
# Conflicts:
#	docs/BUG_HISTORY.md
2026-11-12 18:33:45 +08:00
ClaudeandClaude Opus 5.5 4c7abd7187 fix(report): computed points are not "unsourced"; compute Badhaka (#192, BUG-1852, BUG-1853)
- Indu, Vighati, Gulika, Mandi, Pranapada, 64th navamsa, D5: citations name the
  route that leaves them out instead of "references 未找到"; state renamed
  not_in_domain_route; prompt says computed layers are "已算出,不据此下结论".
- Post-check false_missing_layer withholds a section that calls a computed point
  unsourced or uncomputed (astrologer 8aaf6099 §6 P1).
- yoga_engine.badhaka_facts: Badhaka house/sign/lord/placement/occupants per
  badhaka-obstacle-planet-guide.md §二, on modules and advanced_systems; the
  report facts carry it so health_obstruction is no longer capped at D.
- Dignity check: a word right before the next planet belongs to that planet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-12 18:33:31 +08:00
jesse-ux 6f1b9ac33f docs(tests): record verified results for #205
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m47s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m15s
Independent Staging Quality Gate / publish (pull_request) Skipped
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m7s
Independent Staging Quality Gate / validate-python (push) Successful in 6m33s
Independent Staging Quality Gate / publish (push) Successful in 3m5s
2026-11-12 18:13:12 +08:00
jesse-ux 06d242bae8 merge: refresh #205 against staging 2026-11-12 17:34:44 +08:00
jesse-ux b3a938dafb fix(tests): make lookup timing and preset source checks deterministic (#205) 2026-11-12 17:34:15 +08:00
ClaudeandClaude Opus 5.5 56bb1bed95 fix(report): dignity check accepts cancelled debilitation; keep web and case wording backstage (#192, BUG-1851)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m23s
Independent Staging Quality Gate / validate-python (push) Successful in 6m52s
Independent Staging Quality Gate / publish (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 17:22:06 +08:00
ClaudeandClaude Opus 5.5 aa82210005 feat(report): grade line option B, evidence-grade tests (#192)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 16:55:13 +08:00
ClaudeandClaude Opus 5.5 44f181770d wip(report): per-claim evidence grades, skill-loaded reading prompt, per-section post-check (#192)
UI part pending owner review of the prototype.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 16:51:57 +08:00
ClaudeandClaude Opus 5.5 0150a87496 docs(task): brief TASK-flaky-tests-20261112 (#205, BUG-1850, 2026-11-12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 16:39:58 +08:00
Claude 017e650fda Merge #180 preset library gates into staging with #201
Independent Staging Quality Gate / validate-frontend (push) Successful in 4m58s
Independent Staging Quality Gate / validate-python (push) Successful in 6m39s
Independent Staging Quality Gate / publish (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8

# Conflicts:
#	docs/BUG_HISTORY.md
2026-11-12 15:59:32 +08:00
jesse-ux bb689fa610 test(report): align reading fixtures with facts v3 (#201)
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m59s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m2s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-12 15:55:34 +08:00
jesse-ux c86a7e84ab fix(report): scope v3 reading facts to computed evidence
Independent Staging Quality Gate / validate-frontend (pull_request) Failing after 3m12s
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m40s
Independent Staging Quality Gate / publish (pull_request) Skipped
Verify both public report packets and privacy checks. Record the legacy date assertion and front-end schema fixture that require scope approval.
2026-11-12 13:44:52 +08:00
ClaudeandClaude Opus 5.5 b663793cc1 docs(task): brief for report reading facts v3 per astrologer 11-12 review (#201, BUG-1848, 2026-11-12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 13:12:33 +08:00
34 changed files with 2112 additions and 232 deletions
+70
View File
@@ -24973,3 +24973,73 @@
- 防复发:逐条对比回执,覆盖错 profile、未决分支、月亮自指、过期清单、路径边界和多文件错误。
- 关联记录:#180、BUG-1216、BUG-1772。
- 修复版本:`codex/preset-library-gates-20261112`。
## BUG-1848 | 个人报告解读事实缺少领域路由、报告模式与计算边界
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:`/api/professional_report_reference` 的 `reading_facts` 与个人报告解读。
- 现象:综合域把 D10 同时列作图表和阻塞项,引用缺项也进入阻塞清单。事实包缺少报告模式、尊贵度中文、双行运适用宫位和分盘上升点边界。
- 触发条件:生成报告解读事实,领域未路由或未在引用资料中列明某项技法,或者使用双行运、年盘 Mudda、精细分盘或历史重放。
- 根因:报告 v2 把聊天证据卡的参与标签直接压成阻塞状态,未投影图表路由和各项计算的口径及适用范围。
- 修复:v3 输出参与原因、已计算值及领域路由,声明双行运实际检测宫位,附尊贵度中文、Mudda 参数轴、报告模式和上升点距离。继续执行既有精度门控。
- 验证:通过两张公开盘生成报告事实包,对照路由、状态、英文与中文尊贵度、检测宫位、参数来源、精度门控和脱敏结果。完整验收记录见同轮 PROGRESS。
- 防复发:校验全部静态阻塞项都有原因码。用原生引擎验证公开盘边界两侧的分盘上升点并回归内部单号清理。
- 关联记录:#201、#192、BUG-1846、BUG-1844。
- 修复版本:`codex/reading-facts-v3-20261112`。
## BUG-1851 | 个人报告解读没有按占星师的 skill 写,等级由模型自评,退稿时整节消失
- 状态:fixed-pending-verify(staging 待真机)。
- 首次发现 / 最近更新:2026-11-11 / 2026-11-12。
- 影响面:新生成的中文个人报告「解读」。
- 现象:解读只用产品自己的系统提示,没有加载她的 SKILL 与路由;每个领域节末尾由模型自评「置信度:A/B/C」,并沿用「没有用户经历所以最高 B」;公开名人盘样稿出现健康节隐瞒阻塞、D2 尊贵度写错、给已故人物当前建议等问题;任一段违规整份退稿,报告上不显示「解读」。
- 触发条件:所有带解读的个人报告。
- 根因:#113 的解读是一次性按事实写九节,提示词写在 TypeScript 里;没有逐结论的依赖与上限,也没有发布前的数据一致性复查。
- 修复:按占星师 2026-11-12 审核(她的仓库 8aaf6099,handoff/2026-11-12/preset_q3):后端 `scripts/report_render/evidence_grade.py` 按她路由文件 §3–§7 的必查模块给每类结论算等级上限与六维账本(合同 report_evidence_grade_v1,无适配案例最高 C,必需依据缺失为 D);解读加载她的 SKILL.md 与 8 份 references 原文,产品规则移到 references/product_runtime_contract.md 的 report-reading 块,加载清单与哈希存入每份解读;模型逐段给结论类型与等级,复查逐节进行(等级越过上限、依据缺失却说无阻塞、尊贵度与事实不符、禁用词),只把违规的那一节换成「这一节没有通过数据核对,暂不显示。」;页面每段下方显示等级与含义(产品负责人 2026-11-12 选原型方案 B)。
- 验证:DeepSeek v4 pro 两张公开名人盘各一稿:历史回放盘九节通过、无当前建议、健康 Badhaka 段为 D、D2 写入旺、两套大运不再按主星名比冲突、双行运只用于第 7 宫;在世盘因「口径」被拦一节。评测发现的尊贵度误判(落陷取消)与正文写检索和案例校准两处已修,并补回归。
- 防复发:`tests/test_report_evidence_grade_20261112.py`;`frontend/tests/report-reading-20261027.test.tsx` 的上限、无阻塞、尊贵度、逐节不显示用例;前后端结论类型一致性测试。
- 关联记录:#192、#201、#180、BUG-1846、BUG-1848、BUG-1794、BUG-1834。
- 修复版本:`claude/report-reading-grade-20261112`。
## BUG-1850 | 回答时钟回归依赖真实耗时,资料库校验隐式选中旧导出
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:咨询证据查表回归、预置资料库来源校验与 CLI。
- 现象:前端回归在负载下收到 `empty_answer`,原断言要求 `answer_truncated`。资料库定向测试在旧导出存在时收到哈希不匹配。
- 触发条件:真实测试计时先于首段正文到点,或没有显式指定资料库固定来源。
- 根因:旧夹具用短时真实等待,无法固定超时与首段正文的顺序。资料库测试从旧导出路径取输入,并以单个文件是否存在作为跳过条件。
- 修复:接收首段正文后触发真实回答时钟订阅的受控 deadline。保留截断、零扣点、单时钟和中止回执断言。资料库测试要求显式来源并比对 Git HEAD 或所有固定文件哈希,CLI 要求显式选择来源模式。
- 验证:四个 CPU 负载进程下,原测试文件 20 遍有 4 遍失败,修订后 20 遍通过。固定来源的资料库测试通过,错误来源与缺失目录均失败。全量结果见同轮进度。
- 防复发:检查实际 deadline 对象、正文已发出和查表次数。回归 CLI 缺少来源选项及跳过来源时禁止写清单。
- 关联记录:#205、BUG-1051、BUG-1053、BUG-1142、BUG-1847。
- 修复版本:`codex/flaky-tests-20261112`。
## BUG-1852 | 个人报告解读把已算出的 Indu、Gulika 等写成「参考资料里没有对应条目」
- 状态:fixed-pending-verify(staging 待真机)。
- 首次发现 / 最近更新:2026-11-12(产品负责人真机导出 #192 报告后复查)/ 2026-11-12。
- 影响面:新生成的中文个人报告「解读」的「依据与边界」节;聊天证据卡共用 `consult_card_domain_facts.py` 的引用,Vighati、Indu、第 64 个 Navamsa、D5 四行的引用文字在聊天里也随之改变(只改文字,状态不变)。
- 现象:解读写「Vighati、Indu Lagna、第 64 个 Navamsa、Gulika、Mandi、Pranapada 在参考资料里没有对应条目,故不参与」,而同一份报告的特殊上升与副行星表已列出这些点的数值。占星师 2026-11-12(8aaf6099 §6 P1)已点名「Indu 写 references 未找到」是错的。
- 触发条件:所有带解读的个人报告。
- 根因:`scripts/consult_card_domain_facts.py` 的静态引用写「references/ 未找到 X 条目」(一次局部检索的结论),facts v3(#201)把它映射成状态 `no_reference_entry` 并原样作为 source 交给模型;产品规则块又让模型按「references 没有本领域条目」写原因。实际情况是:这些点已经算出,只是占星师路由文件该领域的必查模块没有列它们。BUG-1851 的复查只查等级、无阻塞、尊贵度与禁用词,没有查「已算出却说没有来源 / 没有计算」,所以她列的这条反例没被拦住。
- 修复:引用改为指出具体路由(如「strict-workflow-router.md §5 必查模块与 event_judgment_wealth.md §3 未列 Indu Lagna;本领域不据此判断」);状态 `no_reference_entry` 改名 `not_in_domain_route`;report-reading 规则块写明带 computed 的层要写「已算出,本报告不用它下结论」;复查新增 `false_missing_layer`:某一分句点名已算出的点(Indu、Vighati、Ghati、Gulika、Mandi、Pranapada、第 64 个 Navamsa、Badhaka)又说没有条目、没有来源或没有计算时,只把那一节换成说明。
- 验证:`frontend/tests/report-reading-20261027.test.tsx` 新用例(报告原句被拦、如实写法通过、未算出的点说没算通过);`tests/test_reading_facts_v3_20261112.py` 检查 Indu 引用不再含「未找到」。
- 防复发:同上两个测试;状态名不再暗示检索结论。
- 关联记录:#192、#201、BUG-1851、BUG-1848;占星师 8aaf6099 §6 P1。
- 修复版本:`claude/report-reading-grade-20261112`。
## BUG-1853 | 健康领域列了 Badhaka,但引擎从未计算,解读只能写「关键依据缺失」
- 状态:fixed-pending-verify(staging 待真机)。
- 首次发现 / 最近更新:2026-11-12(产品负责人真机导出 #192 报告后复查)/ 2026-11-12。
- 影响面:个人报告解读健康节的「阻碍」结论(health_obstruction 恒为 D);聊天健康领域证据卡的 Badhaka 一行恒为缺口。
- 现象:报告健康节写「本次所需的 Badhaka 计算没有完成,属关键依据缺失」,全报告搜不到 Badhaka 数据。
- 触发条件:所有星盘。
- 根因:健康参与层读取 `worksheets.advanced_systems.badhaka` / `worksheets.d1_rasi_bhava.badhaka`,但没有任何模块写这两个路径;`yoga_engine.badhaka_house` 只在瑜伽规则里用,没有进工作表。BUG-1851 评测把「Badhaka 段为 D」当作正确行为记录,没有追问为什么缺。
- 修复:新增产品模块 `scripts/report_render/badhaka_facts.py`,按 references/badhaka-obstacle-planet-guide.md §二(与占星师 8aaf6099 同一文件)从已算好的 D1 位置给出 Badhaka 宫、星座、宫主及其落点与尊贵度、宫内行星,只给位置不下结论,记录带 `product_computation_pending_astrologer_review`;`consult_card_domain_facts` 的健康 Badhaka 一行改读它(聊天健康证据卡同时受益);已在 references/product_runtime_contract.md G 节公告为待她审核的产品计算。第一版(staging 23e5e6c0)把函数写进了她的 `yoga_engine.py` 并在 `jyotish_engine.py` 挂钩,违反产品负责人 2026-11-07「她的 skill 照原样跑、产品差异放单独公告的产品文件」规则,后续提交已移出,两文件与 6f1b9ac3 无差异;报告事实把这条小记录随 executed 一起交给模型;等级合同的路由说明改为「health-timing-strict §7 + badhaka-obstacle-planet-guide.md §二」(她的路由 §7 本身没有列 Badhaka)。
- 验证:`tests/test_badhaka_facts_20261112.py`(巨蟹、狮子、双子三类上升对照来源表);`tests/test_reading_facts_v3_20261112.py` 两张公开盘 Badhaka 为 executed、宫位在 7/9/11、带待审核状态、引擎工作表里没有 badhaka、health_obstruction 上限不再是 D。公开盘 A(处女上升)得第 7 宫双鱼、宫主木星;B(摩羯上升)得第 11 宫天蝎、宫主火星,与来源表一致。
- 防复发:同上。
- 关联记录:#192、BUG-1851、BUG-1852。
- 修复版本:`claude/report-reading-grade-20261112`。
@@ -0,0 +1,30 @@
# 回答时钟与资料库来源测试 · 2026-11-12
Gitea #205,BUG-1850。staging 基线 `017e650fdafac4a841487dbba4aca803c761dad8`。BUG-1849 已由并行的 #204 占用,沿用本单任务书指定的 BUG-1850。
## 回答时钟
- 原用例在查表和短文本分段中使用真实延迟。计算完成时答案时钟已经启动,查表、模型启动、Mastra 流处理及正文放行都消耗同一预算。负载增大时,300 ms 到点前可能还没有可交付正文。运行时此时返回 `empty_answer`,已有正文才记为 `answer_truncated`,两种结果均不扣点。这符合 BUG-1051 与 BUG-1053 的运行时契约。
- Windows 以四个独立忙循环进程加负载,原文件串行跑 20 遍,4 遍失败且均为指定用例的 `empty_answer`。同文件另一条依赖工具阶段预算的用例在 20 遍中均通过。
- 保留真实 Agent、时钟及查表。测试捕获首次答案 deadline,假模型在发送部分正文后等待,客户端收到首段正文才触发原 deadline。通过 signal 数量和身份核对查表未新建 deadline。
| 项目 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 回答时钟用例的夹具 | 40 ms 查表、每段 25 ms、真实 300 ms deadline | 客户端收到首段后触发受控 deadline | 将超时固定在正文已输出的边界,保留原四项断言。 |
| 资料库导出校验条件 | 隐式读取旧目录,按一份资料文件是否存在决定跳过 | 仅在未配置环境变量时跳过;配置后比对 Git HEAD 或所有固定来源文件哈希 | 避免把缺失或不匹配的显式来源作为跳过处理。原有文件、哈希和摘录断言仍执行。 |
| 资料库 CLI | 未指定来源时尝试默认目录 | 必须显式选 `--her-export` 或 `--skip-her-export`,两者互斥 | 区分固定来源验证与开发检查。 |
## 验证
- 修订后的同一前端测试文件在四个负载进程下连续跑 20 遍,15 项用例每遍均通过。结束后按各进程 PID 核对其均已退出。
- Python 资料库定向:环境变量未设置时 39 项通过、2 项按预期跳过。指定与固定 Git 版本逐文件校验一致的纯导出时 41 项通过。显式指定错误 Git HEAD、文件哈希不一致或目录缺失时均以退出码 1 结束并给出「来源与 VERIFIED.json 固定的版本不一致」。
- 资料库 CLI 未选来源选项时退出码 2;显式跳过来源和固定导出两种模式均验证 44 条资料。跳过来源仍无法写 `VERIFIED.json`。
- 预检确认远端可见、Python 运行时与外部引擎适配器诊断可用。Windows 上碎片扫描与预检定向测试分别在 90 秒、45 秒超时,与此前同机预检记录一致。
- 原任务基线上的首轮前端全量运行出现 Windows 内存不足,部分测试进程未启动;使用有界并发重跑。
- 原任务基线的 Python quick 分支运行 1463 项通过、2 项跳过、3 项失败,另有 29 项子测试通过。三项 Kaala 失败在相同基线 `017e650fd` 独立复现。
- staging 更新至 `56bb1bed9` 后,同步到任务分支。唯一冲突是 Bug 历史的并行追加,保留 #192 的 BUG-1851 原文并在末尾追加本单原定编号 BUG-1850。低并发全量复核以更新后的 staging 为对照。
- 更新基线后按同一 `npm test` 文件列表和 Node TAP runner 复跑,每树 4 个并发。两边各 3652 项、0 项取消,3632 个顶层 TAP 名称及出现次数完全一致。staging 3624 项通过、28 项失败,任务分支 3625 项通过、27 项失败,两边退出码均为 1。唯一仅在基线失败的是本单修订的回答时钟用例。
- 更新基线后的 Python quick 仍为 1463 项通过、2 项跳过、3 项失败,另有 29 项子测试通过。staging `56bb1bed9` 单跑复现同样的三个 `tests/test_upstream_technique_layers.py` Kaala 用例失败。
- 更新基线后的 TypeScript 检查通过,ESLint 0 错误、62 条警告。
- 清理构建缓存后,staging 与任务分支 Next.js 构建均通过,`/` 为静态页面。两边均引用 39 个资源,逐文件 gzip -9 的 JS 共 515860 字节、CSS 共 90886 字节。
- 隐私检查 62 项通过。
@@ -0,0 +1,73 @@
# 报告解读事实包 v3 · 2026-11-12
Gitea #201,BUG-1848。基线 `a3a11c3edf3571ba7aed10956e4639f77bb1667a`,任务分支 `codex/reading-facts-v3-20261112`。
## 参与层原因码
表按 `scripts/consult_card_domain_facts.py` 中的 `_blocked` 行逐条对应。运行时遇到没有值的 packet 叶子仍归入 `blocked`,原因码为 `not_computed`。静态记录与原因表的键必须完全一致。
| 领域 | 层 | 原因码 | citation 依据 |
| --- | --- | --- | --- |
| career | Vighati | `no_reference_entry` | `references/` 未找到 Vighati 条目。 |
| wealth | Indu | `no_reference_entry` | `references/` 未找到 Indu Lagna 财富条目。已有 `special_lagnas.Indu_Lagna.sign` 时附上计算值。 |
| wealth | AL2 | `no_reference_entry` | `event_judgment_wealth.md` §3 未列出从 AL 起第 2 宫。 |
| wealth | AL11 | `no_reference_entry` | `event_judgment_wealth.md` §3 未列出从 AL 起第 11 宫。 |
| marriage | UL2 | `no_reference_entry` | `event_judgment_marriage.md` §2 未列出 UL 起第 2 宫。 |
| health | navamsa64 | `no_reference_entry` | `references/` 未找到第 64 个 Navamsa 条目。 |
| health | Gulika | `no_reference_entry` | `prashna-complete-guide.md` 只在问事章列出 Gulika,本命健康域未列入。 |
| health | Mandi | `no_reference_entry` | `prashna-complete-guide.md` 只在问事章列出 Mandi,本命健康域未列入。 |
| health | Pranapada | `no_reference_entry` | `prashna-complete-guide.md` 列为问事替代计算,本命健康域未列入。 |
| education | D5 | `no_reference_entry` | `references/` 未找到把 D5 标成学业分盘的段落。 |
| migration | D12 | `not_routed` | `strict-workflow-router.md` §2 把 D12 标给家族。 |
| family | D4 | `not_routed` | `birth-time-rectification-decision-tree.md` 把 D4 标给房产与搬迁。 |
| family | D7 | `not_routed` | `strict-workflow-router.md` §2 把 D7 标给子女。 |
| general | D10 | `not_routed` | `strict-workflow-router.md` §2 把 D10 标给事业。 |
| general | D2 | `not_routed` | `strict-workflow-router.md` §2 把 D2 标给财富。 |
报告沿用聊天侧的 `DOMAIN_VARGAS["general"] = ("D10", "D9")` 取图,参与层则按严格路由 §2 把 D10 分给事业。报告保留综合域的 D10 图和已算上升星座,图上标 `routed_for_domain: false`。事业域的 D10 图标为 `true`。财富域沿用职业兑现时的 D10 条件路由。双行运只在领域宫位与 `checked_houses` 重合时列入该域,原生第 7 宫样例仅在婚恋域引用。
| 公开样例 | `blocked` | `not_routed` | `no_reference_entry` | `parameter_sensitive` | `not_applicable` | `withheld` |
| --- | --- | --- | --- | --- | --- | --- |
| A | 19 → 8 | 0 → 2 | 0 → 9 | 2 → 2 | 8 → 8 | 0 → 0 |
| B | 19 → 8 | 0 → 2 | 0 → 9 | 2 → 2 | 8 → 8 | 0 → 0 |
## 事实字段与计算依据
- `observation_date` 沿用显式请求的 `today`,未传时使用事实包选择的观察日。`report_mode`、`subject_life_status` 进入既有 reading 缓存输入,历史重放与默认报告各自保留事实结果。
- `dignity_zh` 复用 `parity_chapters.py` 的 `_VIMSOPAKA_DIGNITY`。与 `upstream_sync_chapters.py` 的 `DIGNITY_ZH` 对比,`Own Sign` 分别是「入庙」「自宫」,`Moolatrikona` 分别是「根本三分」「三方宫」,友敌的英文键分别为 `Friend` / `Enemy` 与 `Friendly Sign` / `Enemy Sign`,中文一致。两张公开盘 D2 木星的 `Exalted` 均为「入旺」。
- 上升点边界重用本命 Swiss Ephemeris 上升点计算的岁差口径、宫制和分盘引擎。按出生时刻两侧向外定位,再二分至 1 秒。最大搜索距离是 240 分钟。分钟确认门控继续控制 D30 和 D60 数据。`purpose_stability` 保留 `not_evaluated`。
- Mudda 的期间仍来自 `worksheets.timing_and_predictive_systems.annual_tajika_pack.mudda_dasha.periods`。`balance_method` 来自同层 `.data.balance_method`,`moon_source` 读取 `.data.moon_source` 或把已有的 `.data.balance_profile` 枚举 `moon_at_birth_time` / `moon_in_varshaphala` 转写成 `natal` / `varshaphala`。两张样例均为 `natal` 和 `nakshatra_arc_angle`,没有对应参数时为 `null`。
| 公开样例 | D1 前 / 后(分钟) | D60 前 / 后(分钟) |
| --- | --- | --- |
| A | 2.467 / 144.567 | 0.017 / 2.450 |
| B | 96.233 / 12.950 | 0.617 / 1.100 |
## 首轮验证
- 预检确认 Gitea 远端可见,外部引擎适配器诊断可运行。Windows 环境的碎片扫描和预检定向测试分别触及 90 秒、45 秒超时。上游交接原文链接返回 404,实现依据为任务书与本仓的真实报告包。
- staging 的旧 Python 定向测试 25 项全过,旧前端定向测试 16 项全过。
- `tests/test_reading_facts_v3_20261112.py` 7 项通过。两张盘分别用原生星盘计算确认 D1、D60 边界前一秒仍在原星座、边界后一侧已换座。`tests/test_repo_privacy_markers.py` 62 项通过。
- TypeScript 检查通过。`npm run lint` 0 error、62 warning。
- Python quick 门禁重试后,任务分支和 staging 基线都通过编译、关键 JSON、能力注册表、来源清单与 BPHS 检查,完整 pytest 未取得退出结果。Windows 并行首轮遇到系统资源不足,前端全量测试未取得可比总数。降并发重跑的 Next.js 编译阶段通过,完整构建和首屏 gzip 仍待核验。
## 事实包契约测试同步
Claude 在工单评论 41990 确认以下测试调整。前端固定样本未使用日期字段,更新其 schema 即可驱动原有写作和 worker 路径。
| 既有断言或契约 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_reading_facts_are_packet_leaves` 与 `test_flag_on_adds_reading_facts_and_keeps_the_report` 的 schema 断言 | `report-reading-facts-v2` | `report-reading-facts-v3` | 按任务书升级报告事实包版本。 |
| API 事实包日期断言 | `facts["today"] == "2026-04-07"` | `facts["observation_date"] == "2026-04-07"` | v3 更名为观察日期,原值和断言强度相同。请求仍传 `today`。 |
| 前端写作和 worker 固定样本的 `schema` | `report-reading-facts-v2` | `report-reading-facts-v3` | 让同一输入样本通过 v3 契约校验,保留重试、取消、错误处理和保存断言。 |
## 续办验证
- Claude 的原规划评论 41881 将任务书指定在任务分支的首提交 `b663793cc`,`a3a11c3edf3571ba7aed10956e4639f77bb1667a` 是 staging 基线。续办按评论 41990 沿用该分支和 PR #202。
- 前端定向套件 16 项全过,先前被旧 schema 拦截的 5 项全部恢复通过。Python 定向套件 32 项全过,覆盖 v3、原有事实包和 #198 的内部引用与隐私约束。
- Python quick 门禁完成,1463 项通过、2 项跳过、3 项失败,另有 29 项 subtest 通过。失败均在 `tests/test_upstream_technique_layers.py`,分别为 `test_public_chart_values_are_deterministic_and_pinned`、`test_dasha_registry_serves_or_fails_closed`、`test_kaala_matches_pyjhora_seed_and_periods`。同一 Windows 环境和 staging 基线逐项复现相同失败。
- TypeScript 检查通过,lint 0 error、62 warning。Next.js 构建通过,prerender manifest 和 `index.html` 确认 `/` 为静态页面。按 HTML 实际引用的 39 个 JS/CSS 资产逐文件 gzip -9 求和,JS 515860 字节、CSS 90827 字节,均与 staging 基线相同。
- `npm test` 首轮运行 3651 项,3618 项通过、33 项失败、0 项取消。首轮仅在分支失败的 5 项(回答时限、账务事务、区块扫描、归档退役迁移和 Better Auth)串行复测后,分支与同一 staging 基线各 5 项全过。
- 使用原始 TAP 重跑 `npm test`,分支 3651 项,3623 项通过、28 项失败、0 项取消,退出码 1。staging 基线 3651 项,3622 项通过、29 项失败、0 项取消。逐名比较 3631 条顶层 TAP 用例记录,名称和出现次数完全相同。分支的 28 项失败均在基线出现。
- 续办进度记录通过 `tests/test_repo_privacy_markers.py` 的 62 项隐私检查。
+94
View File
@@ -0,0 +1,94 @@
# TASK:两个会误报的测试(2026-11-12)
- Gitea:#205
- 分支:`codex/flaky-tests-20261112`(基于 staging `017e650fdafac4a841487dbba4aca803c761dad8`)
- Bug 编号:BUG-1850。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 一、回答时钟测试靠真实计时,CI 负载高时会失败
测试:`frontend/tests/consult-evidence-lookup-20260927.test.ts:280`,
「a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase」。
用例的写法:
- `answerMs: 300`;
- 查表延迟 40 ms;
- 回答分 6 段,每段延迟 25 ms;
- 期望结果是 `run.failed:answer_truncated`。
实际情况:
- CI 运行 7208、7230 各失败过一次,单独重跑那一项就通过。
- 本机在负载下得到的是 `empty_answer`,不是 `answer_truncated`。
要做的:
1. 先查清楚负载下为什么会变成 `empty_answer`。在 PROGRESS 写明,是测试的时间预算问题,还是产品代码在边界上真的有分支差异。
- 如果是**产品**问题:停下来,选需拍板,不要改产品代码。
2. 如果是测试的问题,把这个用例改成不依赖真实耗时:
- 用注入的时钟或可控的 signal,让“回答阶段超时”在确定的那一步发生;
- 保留四个断言的含义:结果是 `answer_truncated`;`charges` 为 0;回答时钟只启动一次;有 `compose-abort`。
3. 用同样的方式检查同文件里其他依赖 `delayMs` 和预算的用例。只改那些在 4 倍 CPU 负载下会失败的,逐个列进 PROGRESS。
4. 本机验证:开 4 个后台 `yes > /dev/null` 加负载(Windows 用等效做法),同文件连续跑 20 遍,0 失败。验证完结束这些负载进程,按 PID 结束,**不要按名字批量结束进程**。
## 二、预置资料库测试默认读旧导出
测试:`tests/test_preset_evidence_library.py::test_every_her_file_exists_sha_matches_and_quotes_are_verbatim`。
- 它默认读 `/workspace/up-run-r40`(`scripts/preset_evidence/library.py` 里的 `DEFAULT_HER_EXPORT`)。
- 资料库现在钉的是她来源仓的 `1b33aff61d733213d9df67906ddf16473aafc9bb`,见 `references/preset_evidence_library/VERIFIED.json`。
- 本机如果存在旧的 r40 导出,这个测试会因 `scripts/dasha_analyzer.py` 的哈希不同而失败。CI 上没有这个目录,测试会跳过。
要做的:
1. 去掉对 `/workspace/up-run-r40` 的默认值。只有设置了环境变量 `PRESET_EVIDENCE_HER_EXPORT` 时,才跑需要导出的测试;没设置就跳过,并在 skip 原因里写明。
2. 设置了环境变量时,先核对这个目录和 `VERIFIED.json` 记录的来源是否一致。
- 有 `.git` 的,比对 HEAD;
- 没有的,比对 `VERIFIED.json` 里的文件哈希。
- 不一致时,测试**失败**,并给出明确信息:“来源与 VERIFIED.json 固定的版本不一致”。不能跳过。
3. CLI `python -m scripts.preset_evidence.library` 的 `--her-export` 改为必填,`--skip-her-export` 除外。帮助文字里写明要指向固定版本的来源仓。
4. 同步更新 `SCHEMA.md` 里关于导出目录的说明。
## 要改的文件
- `frontend/tests/consult-evidence-lookup-20260927.test.ts`。如果需要注入时钟,测试辅助文件也可以改;**不改 `frontend/src`**,除非第一节查明是产品问题,那种情况选需拍板。
- `scripts/preset_evidence/library.py`:只改默认路径和 CLI 参数。
- `tests/test_preset_evidence_library.py`、`tests/test_preset_library_gates_20261112.py`:只改导出相关的跳过和核对逻辑。
- `references/preset_evidence_library/SCHEMA.md`:只改导出目录的说明。
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-flaky-tests-20261112.md`
## 不要碰
- `frontend/src/**`
- `frontend/src/lib/consult-preset-prompts.ts`
- 资料库条目和 `VERIFIED.json`
- 冻结文件
## 自测命令
```
cd frontend && npm test
python -m pytest -q tests/test_preset_evidence_library.py tests/test_preset_library_gates_20261112.py
PRESET_EVIDENCE_HER_EXPORT=<固定版本来源目录> python -m pytest -q tests/test_preset_evidence_library.py
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-flaky-tests-20261112.md`,内容包括:
- `empty_answer` 的原因;
- 改了哪些用例,加负载连续 20 遍的结果;
- 资料库测试分别在三种情况下的表现:未设置环境变量、设置为一致的来源、设置为不一致的来源;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- `empty_answer` 是产品代码的边界问题,不是测试的问题。
- 要稳定这个测试,只能放宽断言。
@@ -0,0 +1,149 @@
# TASK:报告解读事实包 v3(按占星师 11-12 审核修事实层,2026-11-12)
- Gitea:#201
- 分支:`codex/reading-facts-v3-20261112`(基于 staging `a3a11c3edf3571ba7aed10956e4639f77bb1667a`)
- Bug 编号:BUG-1848。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
报告「解读」是一次模型调用。模型读的事实来自 `scripts/report_render/reading_facts.py::build_report_reading_facts`,现在的 schema 是 `report-reading-facts-v2`。
占星师 11-12 审核了一张公开名人盘的样稿和它的事实包。她的结论在她仓库的 `8aaf60991c32a84ac30d6d7b6a62c4f166507bce`,文件 `handoff/2026-11-12/preset_q3/README.md`,第五、六、七节;仓库地址 `https://github.com/732642856/yinduzhanxing`。她的结论是:样稿的错误不只是措辞,事实包本身也要先修。
本单只修**事实层**。系统提示、等级上限、发布前复查都在 #192 做,由 Claude 负责。
已确认的事实层问题:
1. **`blocked` 一个词装了好几种意思。**
- `scripts/consult_card_domain_facts.py` 里,参与层的 `blocked` 大多不是“计算失败”,而是:
- “路由把这一层分给了别的领域”。例如 `general` 的 D10、D2,原因写的是“§2 把 D10 标给事业,未标给综合领域”。
- “references 里没有这个领域的条目”。例如 Indu、Vighati、navamsa64,以及只在问事章出现的 Gulika、Mandi、Pranapada。
- 事实包把这些全部归进 `blocked_states.blocked`。模型和占星师都把它们当成了“关键层阻塞”。
- Indu 其实在 `scripts/special_lagnas.py` 里算过,事实包却写成“references 未找到”。她指出:局部检索失败不等于仓内没有这个能力。
2. **D10 状态自相矛盾。** `domains.general.varga_charts` 里有 D10 图,`blocked_states` 却又写着 `general:D10`。要分开三件事:算过没有;这个领域是否路由到它;这条结论是否用到它。
3. **尊贵度只给英文。** D2 里木星的 `dignity: "Exalted"`,模型写成了“入庙”。事实包要同时给中文。
4. **Double Transit 范围不清。** `transits.double_transit.event_house=7`,模型却把它泛化成了健康和整体应期的结论。
5. **出生精度只有一个布尔值 `minute_confirmed`。** 她裁定:D30 能不能用,看的是边界敏感性,不是这一个开关。
6. **没有报告模式。** 公开名人盘里的历史人物已经去世,样稿却给了“当前”的健康、职业和行动建议。
## 要改 / 新建的文件
- `scripts/report_render/reading_facts.py`
- 如果需要计算上升点边界,可以新建 `scripts/report_render/birth_precision_margins.py`,只许调用现有引擎函数。
- `scripts/professional_report_reference.py`:只改 `_reading_facts` 的参数透传。
- `frontend/src/lib/personal-report-reading.ts`:**只许改** `READING_FACTS_SCHEMA` 常量那一行,以及上面的原值/新值注释。这个文件的其他部分由 Claude 在 #192 里改。
- `tests/test_report_reading_facts.py`:只改 schema 字符串断言,写明原值/新值/原因。
- 新建 `tests/test_reading_facts_v3_20261112.py`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-reading-facts-v3-20261112.md`
## 要做的
1. **schema 升到 `report-reading-facts-v3`。** Python 和前端常量同时改。
2. **报告模式。**
- `build_report_reading_facts` 加两个参数:
- `report_mode`:`"living_consultation"`(默认)或 `"historical_replay"`;
- `subject_life_status`:`"living"` / `"deceased"` / `"unknown"`,默认 `"unknown"`。
- 事实里输出 `report_mode`、`subject_life_status` 和 `current_advice_allowed`。只有 `historical_replay` 时,`current_advice_allowed` 为 `false`。
- `subject_life_status="deceased"` 和 `living_consultation` 一起出现时,直接抛 `ValueError`。
- `unknown` 时**不推断**死亡。
- `today` 改名 `observation_date`,保留原值。
- `_reading_facts` 透传请求体里的 `report_mode` 和 `subject_life_status`;缺省走默认值。产品里用户自己的报告不传这两个参数,行为不变。
3. **参与层原因码**,只在 `reading_facts.py` 里做,不改 `consult_card_domain_facts.py`,因为聊天证据卡共用那个文件。
- 在 `reading_facts.py` 建一张表,键是 `(领域, 层 id)`。每个被标成 `blocked` 的参与层,都在表里给一个 `reason_code`:
- `not_routed`:路由把这一层分给了别的领域;
- `no_reference_entry`:references 里没有这个领域的条目;
- `missing_input`:缺输入;
- `not_computed`:没算;
- `calculation_failed`:算了但失败。
- 依据是 `consult_card_domain_facts.py` 里每一行的 citation 原文。逐条列进 PROGRESS。
- 加一个完整性测试:`consult_card_domain_facts.py` 里每个 `blocked` 行都必须在表里有原因码;以后新增 `blocked` 行而不补原因码,测试就失败。
- 层在包里**已经算过**时(例如 Indu 在 special lagnas 里有值),参与项加 `computed: true` 和 `value`,状态仍然是“本领域未使用”。不得写成 executed。
4. **`blocked_states` 重排**:
- `blocked` 只留 `calculation_failed`、`missing_input`、`not_computed`,以及原来就是真失败的项(dasha family、audit、transits、annual)。
- 新增 `not_routed` 和 `no_reference_entry` 两组。
- `parameter_sensitive`、`not_applicable`、`withheld` 不变。
- 测试:在两张公开名人盘上,`health:Gulika`、`Mandi`、`Pranapada` 和 `general:D10` 都**不在** `blocked` 里,而在各自的新组里。
5. **D10 一致性。**
- 每个领域的 `varga_charts` 里,每张图加 `routed_for_domain: true/false`,和参与层状态保持一致。
- 测试:同一个领域、同一张分盘,不得既在 `varga_charts` 里标为 routed,又在 `blocked` / `not_routed` 里出现。
- 如果查明 `general` 不该带 D10 图,在 PROGRESS 说明,**不删图**,只标 `routed_for_domain: false`。
6. **尊贵度中文。**
- 凡是事实里有 `dignity` 的地方,同时给 `dignity_zh`。
- 映射用 `scripts/report_render/parity_chapters.py` 里现成那张表(`"Exalted": ("入旺", ...)`、`"Own Sign": ("入庙", ...)`)。不要新造词。
- 仓内另一张表 `upstream_sync_chapters.py` 把 Own Sign 写成“自宫”。两张表不一致的地方,在 PROGRESS 列出来,本单不改。
- 测试:公开名人盘 D2 里木星的 `dignity_zh == "入旺"`。
7. **Double Transit 范围。**
- `transits.double_transit` 加 `checked_houses: [event_house]`,以及 `scope`,内容是“只检测所列宫位,不代表其他宫位或领域的激活”。
- 各领域如果引用 double transit,只能引用 `checked_houses` 覆盖到的宫位。
- 测试:`event_house=7` 时,`health` 领域下没有 double transit 的激活结论。
8. **出生精度。**
- 新增 `birth_precision`,内容如下:
- `minute_confirmed`:原值;
- `purpose_stability: "not_evaluated"`;
- `basis`:一句英文,说明“按用途的区间合同(她 2026-11-09 的 interval_rectification 交接)尚未接入”;
- `ascendant_margins`:对事实包里出现的每张分盘,给出上升星座在出生时刻**往前**、**往后**各多少分钟会变,封顶 240 分钟。
- `ascendant_margins` 用现有引擎函数,只重算上升点:在出生时刻前后找到上升点所在分盘星座变化的时刻,用二分法,精度 ≤ 0.5 分钟。不要自己写天文公式。
- 现有的“没确认到分钟就隐藏 D30 等精密分盘”的门控,**本单不改**。
- 测试:
- 两张公开名人盘的 D1 margin 都大于 0;
- D60 margin ≤ D1 margin;
- 结果可重复,同输入同输出。
9. **Mudda 参数轴。**
- `annual.mudda_dasha` 增加 `moon_source`(natal / varshaphala)和 `balance_method`,取自 packet 里已有的字段。
- 包里没有的字段写 `null`,不推断。在 PROGRESS 写明取自哪个 packet 路径。
10. **不含身份信息。** 测试:事实 JSON 里不出现姓名、出生地名称和经纬度。原有的 `_birth_parameters` 已经不含这些,加一个断言把它锁住。
## 不要碰
- `scripts/consult_card_domain_facts.py`:聊天在用,本单只读。
- 冻结文件:
- `scripts/jyotish_engine.py`
- `scripts/jyotish_api_server.py`
- `scripts/yoga_engine.py`
- `scripts/consultation_native_layers.py`
- `scripts/shadbala*.py`
- `scripts/narayana_*.py`
- `scripts/special_lagnas.py`
- `frontend/**`。唯一例外是上面说的那一行常量。
- `references/**`
- `scripts/preset_evidence_library.py`、`references/preset_evidence_library/**`:#180 在改。
## 验收标准(写进新测试)
1. 两张公开名人盘按产品路径生成事实:`build_professional_report_reference_packet` → `build_report_reading_facts`,参数与 `_reading_facts` 一致。上面第 2–10 条的断言全部通过。
2. `historical_replay` 时 `current_advice_allowed=false`。`deceased` 配 `living_consultation` 时抛错。
3. 原因码完整性测试通过。
4. 既有的 `tests/test_report_reading_facts.py` 和 `tests/test_reading_facts_internal_refs_20261112.py` 都通过。前一个只改 schema 字符串。
5. `#\d` 零命中,即 #198 的测试仍然通过。
## 自测命令(在仓库根目录)
```
python -m pytest -q tests/test_reading_facts_v3_20261112.py tests/test_report_reading_facts.py tests/test_reading_facts_internal_refs_20261112.py
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
cd frontend && npx tsc --noEmit -p . && npx eslint .
```
## 进度记录
写在 `docs/tasks/PROGRESS-reading-facts-v3-20261112.md`,内容包括:
- 原因码表,逐行列出,并写明依据;
- 两张盘修改前后的 `blocked_states` 计数;
- D10 查明的原因;
- 两张尊贵度表的差异;
- Mudda 字段取自哪个路径;
- `ascendant_margins` 的样例值;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- 某个 `blocked` 行的 citation 看不出属于哪种原因码。
- 计算上升点边界需要改冻结文件。
- 修改会让聊天证据卡的输出变化。
+12
View File
@@ -4866,6 +4866,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.personal-report-reading-part p { font-size: 17px; line-height: 1.85; }
.personal-report-reading-part { scroll-margin-top: 80px; padding-top: var(--space-4); }
.personal-report-reading-part + .personal-report-reading-part { margin-top: var(--space-5); }
/* #192 (owner 2026-11-12, prototype option B): under each graded paragraph a
small grey line with the grade and its meaning; a withheld section is one
muted line in its place. */
.personal-report-reading-paragraph { margin: 0 0 var(--space-5); }
.personal-report-md-article .personal-report-reading-paragraph > p { margin-bottom: var(--space-1); }
.personal-report-md-article .personal-report-reading-part p.personal-report-evidence-grade {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.6;
}
.personal-report-reading-withheld { color: var(--color-ink-tertiary); }
.personal-report-reading-number {
display: block;
color: var(--color-action-strong);
@@ -1,7 +1,10 @@
import {
READING_SECTION_WITHHELD_TEXT,
REPORT_READING_ANCHOR,
REPORT_READING_BASIS_LABEL,
REPORT_READING_HEADING,
evidenceGradeTag,
readingSectionView,
type ReportReading,
} from "@/lib/personal-report-reading-contract";
import { readingPartAnchor } from "@/lib/report-toc-groups";
@@ -13,6 +16,10 @@ import { readingPartAnchor } from "@/lib/report-toc-groups";
*
* Reader direction A (2026-11-07): a row of section chips under the heading,
* and each section numbered and anchored so the grouped contents can land on it.
*
* #192 (owner 2026-11-12, prototype option B): under each graded paragraph one
* small grey line gives the grade and its meaning; a section that failed the
* post-check shows one line in its place, the other sections stay.
*/
export function ReportReadingSection({ reading }: { reading: ReportReading }) {
return (
@@ -24,15 +31,25 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
<a href={`#${readingPartAnchor(section.id)}`} key={section.id}>{section.title}</a>
))}
</nav>
{reading.sections.map((section, index) => (
<div className="personal-report-reading-part" id={readingPartAnchor(section.id)} key={section.id}>
<span className="personal-report-reading-number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
<h3>{section.title}</h3>
{section.paragraphs.map((paragraph, paragraphIndex) => (
<p key={paragraphIndex}>{paragraph}</p>
))}
</div>
))}
{reading.sections.map((section, index) => {
const view = readingSectionView(section);
return (
<div className="personal-report-reading-part" id={readingPartAnchor(section.id)} key={section.id}>
<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.paragraphs.map((paragraph, paragraphIndex) => (
<div className="personal-report-reading-paragraph" key={paragraphIndex}>
<p>{paragraph.text}</p>
{paragraph.grade ? (
<p className="personal-report-evidence-grade">{evidenceGradeTag(paragraph.grade)}</p>
) : null}
</div>
))}
</div>
);
})}
</section>
<p className="personal-report-basis-label">{REPORT_READING_BASIS_LABEL}</p>
</>
@@ -37,11 +37,39 @@ export const MIN_TOTAL_CHARS = 1_500;
export const MAX_TOTAL_CHARS = 18_000;
export const MAX_SECTION_PARAGRAPHS = 6;
/**
* Claim types and grades of the evidence-grade contract (astrologer 2026-11-12,
* her repo 8aaf6099 handoff/2026-11-12/preset_q3 §1.3–1.4, §5.2). The backend
* (scripts/report_render/evidence_grade.py, `report_evidence_grade_v1`) fixes each
* claim type's ceiling; ids here must equal its CLAIM_TYPES keys.
*/
export const EVIDENCE_GRADE_CONTRACT = "report_evidence_grade_v1";
export const REPORT_CLAIM_TYPES = [
"character_structure",
"career_promise",
"wealth_earning",
"wealth_retention",
"relationship_structure",
"health_pressure",
"health_obstruction",
"timing_window",
] as const;
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. */
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({
id: z.enum(SECTION_IDS),
paragraphs: z.array(z.string().min(1)).min(1).max(MAX_SECTION_PARAGRAPHS),
paragraphs: z.array(draftParagraph).min(1).max(MAX_SECTION_PARAGRAPHS),
}).strict()).length(REPORT_READING_SECTIONS.length),
}).strict();
export type ReportReadingDraft = z.infer<typeof reportReadingDraftSchema>;
@@ -52,7 +80,32 @@ const storedSection = z.strictObject({
title: z.string().min(1).max(12),
paragraphs: z.array(z.string().min(1).max(MAX_PARAGRAPH_CHARS)).min(1).max(MAX_SECTION_PARAGRAPHS),
});
/** Version 1: the eight sections before 2026-11-07. Version 2 adds 「依据与边界」. */
const storedParagraphV3 = z.strictObject({
text: z.string().min(1).max(MAX_PARAGRAPH_CHARS),
claimType: z.enum(REPORT_CLAIM_TYPES).optional(),
grade: z.enum(EVIDENCE_GRADES).optional(),
});
/** A section that failed the post-check is kept as a visible placeholder (owner 2026-11-12). */
const storedSectionV3 = z.union([
z.strictObject({
id: z.enum(SECTION_IDS),
title: z.string().min(1).max(12),
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}$/),
}),
]);
/** 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}$/),
});
/** 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({
version: z.literal(1),
@@ -64,5 +117,46 @@ export const reportReadingSchema = z.union([
language: z.literal("zh"),
sections: z.array(storedSection).length(REPORT_READING_SECTIONS.length),
}),
z.strictObject({
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),
}),
sections: z.array(storedSectionV3).length(REPORT_READING_SECTIONS.length),
}),
]);
export type ReportReading = z.infer<typeof reportReadingSchema>;
export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGrade }>;
/** 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 EVIDENCE_GRADE_LABEL = "证据等级";
/**
* The line under a graded paragraph (owner 2026-11-12, prototype option B): the
* grade and its meaning in one short line, no tap to open. Contract
* report_evidence_grade_v1 (astrologer 2026-11-12 §5.2).
*/
export const EVIDENCE_GRADE_MEANING: Readonly<Record<EvidenceGrade, string>> = {
A: "在明确的案例集里经过较强的独立验证",
B: "有对得上的真实案例支持,几种独立方法方向一致",
C: "有星盘依据,还没有用真实案例校准过",
D: "关键依据缺失,暂不能判断",
};
/** 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" }> {
if (!("status" in section)) return { status: "ok", paragraphs: section.paragraphs.map((text) => ({ text })) };
if (section.status === "withheld") return { status: "withheld" };
return { status: "ok", paragraphs: section.paragraphs.map(({ text, grade }) => (grade ? { text, grade } : { text })) };
}
/** 「证据等级 C:有星盘依据,还没有用真实案例校准过」 */
export function evidenceGradeTag(grade: EvidenceGrade): string {
return `${EVIDENCE_GRADE_LABEL} ${grade}:${EVIDENCE_GRADE_MEANING[grade]}`;
}
+247 -66
View File
@@ -8,13 +8,16 @@
* the same leaves the chat card uses). It is optional: any failure, timeout or
* rejected draft leaves the report without a reading, never failed.
*/
import { HOUSE_LIFE_AREA_RULE } from "./consultation-thinking-plan";
import {
EVIDENCE_GRADES,
EVIDENCE_GRADE_CONTRACT,
GRADED_SECTION_IDS,
MAX_PARAGRAPH_CHARS,
MAX_TOTAL_CHARS,
MIN_TOTAL_CHARS,
REPORT_READING_SECTIONS,
reportReadingDraftSchema,
type EvidenceGrade,
type ReportReading,
} from "./personal-report-reading-contract";
@@ -36,25 +39,217 @@ const BANNED_PHRASES = [
"本仓", "检测器", "字段", "口径",
// 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.
"联网", "全网", "网络资料", "外部资料", "真实案例", "MEVG",
];
/** 「一定会」「必然会」 are fate claims; 「不一定会」 is the opposite and stays allowed. */
const CERTAINTY = /(?<!不)(一定会|必然会)/;
const DISCLAIMER = /仅供参考|不构成|请咨询专业|不能替代/g;
/** 「没有关键阻塞」 and the like: allowed only when the claim's required layers are all available (her §6 P0). */
const NO_BLOCKER = /(没有|无|不存在)(任何)?(关键)?(层)?(的)?阻塞/;
const PLANETS_ZH = ["太阳", "月亮", "火星", "水星", "木星", "金星", "土星", "罗睺", "计都"] as const;
/**
* Points the engine computes that a draft must not call unsourced or uncomputed
* (her §6 P1: Indu written as "references 未找到" although special_lagnas has it; BUG-1852).
*/
const POINT_ALIASES: Readonly<Record<string, readonly string[]>> = {
Indu: ["Indu"],
Vighati: ["Vighati"],
Ghati: ["Ghati"],
Gulika: ["Gulika"],
Mandi: ["Mandi", "Maandi"],
Pranapada: ["Pranapada"],
navamsa64: ["第64个 Navamsa", "第 64 个 Navamsa", "第64个Navamsa", "64th Navamsa"],
Badhaka: ["Badhaka", "阻碍星"],
};
const MISSING_CLAIM = /没有.{0,6}条目|没有.{0,4}来源|无来源|参考资料.{0,8}没有|资料里没有|未计算|没有计算|没有算|计算没有完成|未完成计算|没有完成/;
/** Dignity words a draft may not invent (her §6 P1: D2 Exalted written as own sign). */
const CHECKED_DIGNITIES = ["入旺", "入庙", "落陷", "根本三分"] as const;
// 原值:含 "latin"(段落里出现任何英文字母即退稿)
// 新值:去掉 latin;技法名、分盘代号(D10、A10、UL)和 A/B/C 置信度是专业写法的一部分
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按她的专业写法写
/**
* Why a draft or one of its sections was not used. Whole-draft reasons leave the
* report without a reading; section reasons withhold that section only and the
* page says so in its place (owner 2026-11-12).
*/
export type ReadingRejection =
| "shape"
| "field_name"
| "banned_phrase"
| "too_long"
| "too_short"
| "disclaimer";
| "disclaimer"
| "claim_type_missing"
| "claim_type_section"
| "grade_above_cap"
| "false_no_blocker"
| "dignity_mismatch"
| "false_missing_layer"
| "all_sections_withheld";
/** The draft becomes the stored reading only when every check passes. */
export function acceptReportReadingDraft(value: unknown):
| { ok: true; reading: ReportReading }
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown }>>;
function claimsOf(facts: Record<string, unknown> | null): Claims {
const grades = facts?.evidence_grades as { contract?: unknown; claims?: unknown } | undefined;
return grades?.contract === EVIDENCE_GRADE_CONTRACT && grades.claims && typeof grades.claims === "object"
? grades.claims as Claims
: {};
}
function gradeAtOrBelow(grade: EvidenceGrade, cap: unknown): boolean {
const capIndex = EVIDENCE_GRADES.indexOf(cap as EvidenceGrade);
return capIndex !== -1 && EVIDENCE_GRADES.indexOf(grade) >= capIndex;
}
/** Each planet's dignity words anywhere in the facts (D1, D9 and every domain varga). */
function dignitiesOf(facts: Record<string, unknown> | null): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
const zh: Record<string, string> = {
Sun: "太阳", Moon: "月亮", Mars: "火星", Mercury: "水星", Jupiter: "木星", Venus: "金星", Saturn: "土星", Rahu: "罗睺", Ketu: "计都",
};
const add = (planet: string, word: unknown) => {
const name = zh[planet];
if (!name || typeof word !== "string" || !word) return;
if (!out.has(name)) out.set(name, new Set());
out.get(name)!.add(word);
};
const planets = (facts?.planets ?? {}) as Record<string, Record<string, unknown>>;
for (const [planet, row] of Object.entries(planets)) {
if (!row || typeof row !== "object") continue;
add(planet, row.d1_dignity_zh);
add(planet, row.d9_dignity_zh);
}
const domains = (facts?.domains ?? {}) as Record<string, { varga_charts?: Record<string, unknown> }>;
for (const domain of Object.values(domains)) {
for (const chart of Object.values(domain?.varga_charts ?? {})) {
if (!chart || typeof chart !== "object") continue;
for (const [planet, row] of Object.entries(chart as Record<string, unknown>)) {
if (row && typeof row === "object") add(planet, (row as Record<string, unknown>).dignity_zh);
}
}
}
return out;
}
/** Point ids the facts mark as computed, in any domain. */
function computedPointsOf(facts: Record<string, unknown> | null): Set<string> {
const out = new Set<string>();
const domains = (facts?.domains ?? {}) as Record<string, { participation?: unknown }>;
for (const domain of Object.values(domains)) {
if (!Array.isArray(domain?.participation)) continue;
for (const row of domain.participation as Record<string, unknown>[]) {
if (typeof row?.id === "string" && (row.state === "executed" || row.computed === true)) out.add(row.id);
}
}
return out;
}
/** A clause that names a computed point and says it has no source or was not computed. */
function falseMissingLayer(text: string, computed: Set<string>): boolean {
for (const clause of text.split(/[,。;;,!?\n]/)) {
if (!MISSING_CLAIM.test(clause)) continue;
for (const [id, aliases] of Object.entries(POINT_ALIASES)) {
if (computed.has(id) && aliases.some((alias) => clause.includes(alias))) return true;
}
}
return false;
}
/** A planet name followed closely by a dignity word the facts never give that planet. */
function dignityMismatch(text: string, dignities: Map<string, Set<string>>): boolean {
if (dignities.size === 0) return false;
for (const planet of PLANETS_ZH) {
let from = 0;
for (let at = text.indexOf(planet, from); at !== -1; at = text.indexOf(planet, from)) {
from = at + planet.length;
const window = text.slice(from, from + 14);
const nextPlanet = Math.min(...PLANETS_ZH.map((other) => {
const index = window.indexOf(other);
return index === -1 ? window.length : index;
}));
const clause = window.slice(0, nextPlanet).split(/[,。;;,]/)[0] ?? "";
// The clause ran up to the next planet without punctuation: a word at its end describes that planet.
const nextFollows = nextPlanet < window.length && clause.length === nextPlanet;
for (const word of CHECKED_DIGNITIES) {
// 「落陷取消」 (cancelled debilitation) in the facts allows 「落陷」 in the text.
const known = [...(dignities.get(planet) ?? [])];
// 「木星、落陷金星」 (DeepSeek eval 2026-11-12, public chart A): 「落陷」 belongs to 金星.
if (nextFollows && clause.endsWith(word)) continue;
if (clause.includes(word) && !known.some((given) => given.includes(word))) return true;
}
}
}
return false;
}
type DraftSection = Readonly<{
id: string;
paragraphs: ReadonlyArray<{ text: string; claim_type: string | null; grade: EvidenceGrade | null }>;
}>;
type CheckedSection = ReportReading extends infer R
? R extends { version: 3; sections: ReadonlyArray<infer S> } ? S : never
: never;
function checkSection(
section: DraftSection,
title: string,
claims: Claims,
dignities: Map<string, Set<string>>,
computed: Set<string>,
): { section: CheckedSection; chars: number } {
const withheld = (reason: ReadingRejection) => ({
section: { id: section.id, title, status: "withheld" as const, reason },
chars: 0,
}) as { section: CheckedSection; chars: number };
const graded = (GRADED_SECTION_IDS as readonly string[]).includes(section.id);
const anyClaimMissing = Object.values(claims).some((claim) => claim?.cap === "D");
let disclaimers = 0;
let chars = 0;
const paragraphs: { text: string; claimType?: string; grade?: EvidenceGrade }[] = [];
for (const raw of section.paragraphs) {
const text = raw.text.trim();
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 (dignityMismatch(text, dignities)) return withheld("dignity_mismatch");
if (falseMissingLayer(text, computed)) return withheld("false_missing_layer");
disclaimers += text.match(DISCLAIMER)?.length ?? 0;
chars += text.length;
if (!graded) {
if (NO_BLOCKER.test(text) && anyClaimMissing) return withheld("false_no_blocker");
paragraphs.push({ 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 });
}
if (disclaimers > 1) return withheld("disclaimer");
return {
section: { id: section.id, title, status: "ok", paragraphs } as CheckedSection,
chars,
};
}
export type ReadingSkillMeta = Readonly<{
name: string;
version: string;
loaded: ReadonlyArray<Readonly<{ path: string; sha256: string }>>;
}>;
/**
* The draft becomes the stored reading only when its shape holds and at least one
* section passes. Each section is checked on its own: a section that breaks a rule
* is withheld and shown as withheld, the others stay (owner 2026-11-12). Grades are
* checked against the backend's per-claim ceilings in the facts (astrologer Q3-2).
*/
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);
if (!parsed.success) return { ok: false, reason: "shape" };
@@ -62,33 +257,26 @@ export function acceptReportReadingDraft(value: unknown):
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
return { ok: false, reason: "shape" };
}
let total = 0;
for (const section of sections) {
let disclaimers = 0;
for (const raw of section.paragraphs) {
const paragraph = raw.trim();
if (!paragraph) return { ok: false, reason: "shape" };
if (paragraph.length > MAX_PARAGRAPH_CHARS) return { ok: false, reason: "too_long" };
if (/[_={}<>#*|`]/.test(paragraph)) return { ok: false, reason: "field_name" };
if (BANNED_PHRASES.some((phrase) => paragraph.includes(phrase)) || CERTAINTY.test(paragraph)) return { ok: false, reason: "banned_phrase" };
disclaimers += paragraph.match(DISCLAIMER)?.length ?? 0;
total += paragraph.length;
}
if (disclaimers > 1) return { ok: false, reason: "disclaimer" };
}
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));
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);
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: 2,
version: 3,
language: "zh",
sections: sections.map((section, index) => ({
id: section.id,
title: REPORT_READING_SECTIONS[index]!.title,
paragraphs: section.paragraphs.map((paragraph) => paragraph.trim()),
})),
},
contract: EVIDENCE_GRADE_CONTRACT,
skill: { name: skill.name, version: skill.version, loaded: skill.loaded.map((file) => ({ ...file })) },
sections: checked.map(({ section }) => section),
} as ReportReading,
};
}
@@ -96,40 +284,15 @@ export function acceptReportReadingDraft(value: unknown):
export function acceptReadingFacts(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const facts = value as Record<string, unknown>;
// 原值:report-reading-facts-v1
// 新值:report-reading-facts-v2(占星师方案 item 10 的完整契约)
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill、导出保留审计
// 原值:report-reading-facts-v2
// 新值:report-reading-facts-v3
// 原因: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;
return facts;
}
export const READING_FACTS_SCHEMA = "report-reading-facts-v2";
/**
* 产品 2026-11-07 占星师方案(item 5、8、10):报告解读按她的 skill 写。
* 证据在前、判断在后;每个领域标 A/B/C;功能吉凶与自然吉凶冲突要说出来;
* 应期双轨(Vimshottari + Narayana,带起止日期)再叠行运;阻塞、参数敏感照实标。
* 旧版(禁术语、禁分数、最多两条依据、时间最多三件事)是普通聊天的白话规则,不再用于报告。
*/
export const REPORT_READING_SYSTEM = [
"你在给一份个人星盘报告写开头的「解读」,按印度占星专业解盘的写法。用户消息里的盘面事实是引擎已经算好的全部材料,你只用这些,不编数据,不另起算法;事实里没有的层就当没有,不补算。",
"写法:证据在前,判断在后。每段先列这一段用到的盘面依据(技法名、星座、宫位、宫主、度数、力量数值、大运起止日期),再写这些依据合起来说明什么,最后落到工作、钱、感情、家里、身体作息上的具体样子。依据用原值写,例如「土星为 9、10 宫主,功能吉星(Yogakaraka),Shadbala 7.2 Rupa(门槛 5)」,不把数值改写成强弱一个字。",
"分领域必须调取的层:事业看 D1 第 10 宫与宫主、D10、A10、AmK、AK、Karakamsha;财富看 2、11 宫与宫主、D2、D11、A2、A11(财富来自职业时再看 D10、A10),并分开说「能不能挣到」和「能不能留下」;感情看 7 宫与宫主、D9、UL、A7、DK、金星;健康看 1、6、8、12 宫,D6、D8,出生分钟确认后才用 D30。事实里某层标为阻塞或缺失,就写明这一层没有参与。",
"功能吉凶:先写上升对应的功能吉星、功能凶星、Yogakaraka。用到某颗星时,自然吉凶和功能吉凶不一致(事实里的 natural_vs_functional_conflicts),要写出冲突来源(它主管哪几宫),并据此降低这一条的置信度。罗睺、计都不单独定功能吉凶。",
"应期双轨:「现在这几年」必须同时写 Vimshottari(大运、小运、细运,起止日期)和 Narayana(当前大运与小运的星座、宫主、起止日期,日期按出生日加年龄换算),再写接下来的两段。两套方向一致就说一致;方向不同,两面都写,不挑一套。然后叠行运:木星、土星、罗睺、计都落在哪个星座,从上升、月亮各算第几宫,是否逆行,有没有双重行运,Sade Sati 是否进行中;再写年盘(Muntha、年主星、Mudda 当前段)。只说到月份,不说具体哪一天。",
"置信度:每个领域节(事业、财富、感情、健康、现在这几年)最后一句写「置信度:A/B/C」并说明理由。A = 已有用户经历核验(本报告没有用户经历,所以不给 A);B = 本命承诺、相关分盘、双轨大运至少三个维度同向,且没有关键层阻塞;C = 只有单一维度、维度之间冲突、关键层阻塞或参数敏感,或出生时间没有确认到分钟。",
"状态标签照实写中文:「阻塞」(缺输入或不能运行)、「参数敏感」(换岁差、交点、宫制或方法版本结果会变)、「待用户经历核验」(算出来了但没有现实事件对照)。KP(Placidus 宫头、KP 岁差)与整宫制结果不同时,写成方法差异,不算矛盾。事实里的 method_differences 至少挑与本节相关的写进去。Yoga 只能写成「候选」,同时写出它的成立条件;没有核对取消条件和现实事件之前,不写成已经成立的结果。",
HOUSE_LIFE_AREA_RULE,
// 原值:依据与边界里要求模型写「外部资料核验与真实案例校准在本报告中没有执行,所以全篇置信度最高为 B」
// 新值:改为只说「没有用户经历可对照」,置信度封顶不变
// 原因:产品负责人 2026-11-08:用户可见文字不出现联网/外部核验字样,后台保留
"九节固定,按顺序:总评(这张盘最突出的两三点,各带依据)、性格、事业、财富、感情、健康、现在这几年(双轨应期加行运和年盘)、几条建议(两到四条,和盘面直接相关的具体做法,每条注明依据)、依据与边界(列出:功能吉星/凶星与冲突;本报告实际用到的技法;阻塞和参数敏感的层;本报告没有用户经历可对照,所以全篇置信度最高为 B;岁差、交点、宫制参数)。每节一到六段,每段不超过六百五十字,全篇三千到一万五千字。",
"术语:可以写技法名和分盘代号(Vimshottari、Narayana、D10、A10、UL、AK、AmK、DK、KP、Shadbala、Ashtakavarga、Sade Sati),第一次出现时在括号里用半句中文说明它看什么。行星、星座用中文;交点只叫罗睺、计都。不写字段名、下划线、花括号、代码,不写「本仓」「检测器」「字段」「口径」这类内部说法。",
"红线:不写病名、诊断和身体部位的疾病;健康只说哪段时间要多休息、哪方面的作息要留意。不给投资指令,不点名股票、基金、币。不写「注定」「先苦后甜」「宿命」「一定会」。不预测死亡、必然离婚、怀孕与孩子性别、某天必然发财。婚姻、生死不下确定结论。每节最多一句提醒,不逐句免责。",
"出生时间如果不是确认过的,不把上升、宫位、D10 之后的细分盘说成定论,置信度最高写 C,也不写「校正」「确认」。",
"只输出 JSON:{\"sections\":[{\"id\":\"overview\",\"paragraphs\":[\"……\"]}, …]},九节的 id 依次是 overview、character、career、wealth、love、health、now、advice、basis。",
].join("\n");
export const READING_FACTS_SCHEMA = "report-reading-facts-v3";
export function reportReadingUserMessage(facts: Record<string, unknown>, birthTimeConfirmed: boolean): string {
return [
@@ -139,11 +302,17 @@ export function reportReadingUserMessage(facts: Record<string, unknown>, birthTi
].join("\n");
}
export type ReportReadingWriter = (input: Readonly<{
/** The system prompt and the files it was built from (astrologer §7.3: record what was actually loaded). */
export type ReportReadingPrompt = Readonly<{ system: string; skill: ReadingSkillMeta }>;
export type ReportReadingWriter = ((input: Readonly<{
system: string;
user: string;
signal: AbortSignal;
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>;
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>) & Readonly<{
/** Built once by the server from her live SKILL.md, references and the product contract block. */
prompt: ReportReadingPrompt;
}>;
export type ReportReadingOutcome = Readonly<{
reading: ReportReading | null;
@@ -177,7 +346,11 @@ function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
};
}
/** One attempt plus one retry. Never throws, except when the job itself is aborted. */
/**
* One attempt plus one retry. A draft with withheld sections is kept, and the
* retry is used only if it withholds fewer. Never throws, except when the job
* itself is aborted.
*/
export async function writeReportReading(input: Readonly<{
facts: unknown;
birthTimeConfirmed: boolean;
@@ -189,22 +362,28 @@ export async function writeReportReading(input: Readonly<{
const facts = acceptReadingFacts(input.facts);
if (!facts) return { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 };
const user = reportReadingUserMessage(facts, input.birthTimeConfirmed);
const { system, skill } = input.write.prompt;
let inputTokens = 0;
let outputTokens = 0;
let unavailable = "model_failed";
let best: { reading: ReportReading; withheld: number } | null = null;
for (let attempt = 0; attempt < (input.attempts ?? 2); attempt += 1) {
input.signal?.throwIfAborted();
const timed = timedSignal(input.signal, input.timeoutMs ?? REPORT_READING_TIMEOUT_MS);
try {
const result = await Promise.race([
input.write({ system: REPORT_READING_SYSTEM, user, signal: timed.signal }),
input.write({ system, user, signal: timed.signal }),
timed.lost,
]);
inputTokens += Math.max(0, result.inputTokens);
outputTokens += Math.max(0, result.outputTokens);
const accepted = acceptReportReadingDraft(result.body);
if (accepted.ok) return { reading: accepted.reading, inputTokens, outputTokens };
unavailable = `rejected_${accepted.reason}`;
const accepted = acceptReportReadingDraft(result.body, facts, skill);
if (accepted.ok) {
if (!best || accepted.withheld < best.withheld) best = { reading: accepted.reading, withheld: accepted.withheld };
if (accepted.withheld === 0) break;
} else {
unavailable = `rejected_${accepted.reason}`;
}
} catch {
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
@@ -212,5 +391,7 @@ export async function writeReportReading(input: Readonly<{
timed.dispose();
}
}
return { reading: null, unavailable, inputTokens, outputTokens };
return best
? { reading: best.reading, inputTokens, outputTokens }
: { reading: null, unavailable, inputTokens, outputTokens };
}
+2 -1
View File
@@ -27,7 +27,8 @@ export type ProductContractBlock =
| "chat-method-boundaries"
| "natal-instructions"
| "mevg-blocked-policy"
| "shared-reading";
| "shared-reading"
| "report-reading";
let cached: string | null = null;
+15 -2
View File
@@ -8,7 +8,14 @@ import { factCellText, factStatusLabel } from "./report-fact-table-display";
import type { ReportLanguage } from "./report-language";
import { SIGN_ZH, type SignName } from "./chart-view-labels";
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, type ReportReading } from "./personal-report-reading-contract";
import {
READING_SECTION_WITHHELD_TEXT,
REPORT_READING_BASIS_LABEL,
REPORT_READING_HEADING,
evidenceGradeTag,
readingSectionView,
type ReportReading,
} from "./personal-report-reading-contract";
export type ReportExportBlock = Readonly<{
id: string;
@@ -82,7 +89,13 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
if (language === "zh" && reading?.language === "zh") {
const text = [
`## ${REPORT_READING_HEADING}`,
...reading.sections.flatMap(section => [`### ${section.title}`, ...section.paragraphs]),
...reading.sections.flatMap(section => {
const view = readingSectionView(section);
// #192: the export carries the same grade tags and withheld line 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))];
}),
REPORT_READING_BASIS_LABEL,
].join("\n\n");
add("body:reading", "body", REPORT_READING_HEADING, text);
@@ -4,6 +4,7 @@ import type { z } from "zod";
import type { ResolvedLanguageModel } from "./model.ts";
import { reportReadingDraftSchema } from "@/lib/personal-report-reading-contract";
import type { ReportReadingWriter } from "@/lib/personal-report-reading";
import { reportReadingPrompt } from "./report-reading-skill.ts";
/** One structured call for the report reading (issue #113 R1). Same pattern as the synastry section writer. */
export function createReportReadingWriter(model: ResolvedLanguageModel): ReportReadingWriter {
@@ -13,7 +14,7 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
model: model.model,
instructions: "Follow the system message. Return only the requested JSON.",
});
return async function writeReading(input) {
const writeReading = async (input: Parameters<ReportReadingWriter>[0]) => {
const result = await agent.generate(
[
{ role: "system", content: input.system },
@@ -31,4 +32,6 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
outputTokens: Math.max(0, usage?.outputTokens ?? 0),
};
};
// #192: her SKILL.md + routed references + the product report-reading block.
return Object.assign(writeReading, { prompt: reportReadingPrompt() });
}
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { productContractBlock, PRODUCT_RUNTIME_CONTRACT_FILE } from "../lib/product-runtime-contract.ts";
import { REPORT_CLAIM_TYPES } from "../lib/personal-report-reading-contract.ts";
import type { ReportReadingPrompt } from "../lib/personal-report-reading.ts";
import { jyotishSkillPackage } from "./skill-binding.ts";
/**
* #192 (owner 2026-11-11 decision 2; astrologer 2026-11-12, her repo 8aaf6099
* handoff/2026-11-12/preset_q3 §7.3): the report reading is written with her
* skill, like the chat. The chat binds SKILL.md and opens the references it
* routes to with skill_read; the reading is one structured call with no tools,
* so the references its nine sections route to are quoted in full instead.
*
* Which files: her SKILL.md; the router whose §3–§7 mandatory modules are the
* claim dependencies in scripts/report_render/evidence_grade.py; the event
* judgment skeleton (Promise → Activation → Manifestation → Timing) and the
* career / wealth / marriage judgment files; the precision methodology; the
* functional role profile; the interpretation status contract. Then the
* product's report-reading block. Every file is hashed into the stored reading.
*/
export const REPORT_READING_REFERENCES = [
"references/strict-workflow-router.md",
"references/event_judgment_skeleton.md",
"references/event_judgment_career.md",
"references/event_judgment_wealth.md",
"references/event_judgment_marriage.md",
"references/precision-reading-methodology.md",
"references/functional-role-profile.md",
"references/interpretation_status_contract.md",
] as const;
export const REPORT_READING_SKILL_MARKER = `<jyotish-skill name="${jyotishSkillPackage.name}" use="report-reading">`;
function read(path: string): { text: string; sha256: string } {
const bytes = readFileSync(resolve(jyotishSkillPackage.resolvedPath, path));
return { text: bytes.toString("utf8").replace(/\r\n/g, "\n"), sha256: createHash("sha256").update(bytes).digest("hex") };
}
function skillBody(raw: string): string {
const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(raw);
return (frontmatter ? raw.slice(frontmatter[0].length) : raw).trim();
}
let cached: ReportReadingPrompt | null = null;
/** Built once per process from the live skill tree. */
export function reportReadingPrompt(): ReportReadingPrompt {
if (cached) return cached;
const skill = read("SKILL.md");
const body = skillBody(skill.text);
if (!body) throw new Error(`Skill ${jyotishSkillPackage.name} has no method body for the report reading`);
const references = REPORT_READING_REFERENCES.map((path) => ({ path, ...read(path) }));
const contract = read(PRODUCT_RUNTIME_CONTRACT_FILE);
const rules = productContractBlock("report-reading", { CLAIM_TYPES: REPORT_CLAIM_TYPES.join("、") });
const system = [
`The jyotish-vedic-astrology skill is loaded for a written report. Its full SKILL.md (the astrologer's upstream skill, verbatim) is quoted below, then the references this report routes to, verbatim. Follow its method, routing and truth boundaries. There are no tools in this call: everything you may use is in this message and the chart facts.`,
REPORT_READING_SKILL_MARKER,
body,
...references.map((file) => `<reference path="${file.path}">\n${file.text.trim()}\n</reference>`),
"</jyotish-skill>",
rules,
].join("\n\n");
cached = {
system,
skill: {
name: jyotishSkillPackage.name,
version: jyotishSkillPackage.version,
loaded: [
{ path: "SKILL.md", sha256: skill.sha256 },
...references.map(({ path, sha256 }) => ({ path, sha256 })),
{ path: PRODUCT_RUNTIME_CONTRACT_FILE, sha256: contract.sha256 },
],
},
};
return cached;
}
@@ -92,7 +92,8 @@ const lookupCall = (section: string, text?: string): Turn => ({
finish: "tool-calls",
});
const run = (turns: Turn[], options: { toolPhaseMs?: number; answerMs?: number } = {}) => runNatalAgent(turns, { workflow, ...options });
const run = (turns: Turn[], options: Omit<Parameters<typeof runNatalAgent>[1], "workflow"> = {}) =>
runNatalAgent(turns, { workflow, ...options });
function toolResultText(prompt: unknown[] | undefined, toolName: string) {
return JSON.stringify((prompt ?? []).filter((message) => {
@@ -277,16 +278,40 @@ test("a step that only opens like the released text is not mistaken for a restar
assert.equal(result.state.steps.some((step) => step.name === "answer-restart-dropped"), false);
});
test("a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase", async () => {
test("a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase", { timeout: 20_000 }, async (t) => {
const answerMs = 300;
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
const deadlines: AbortController[] = [];
t.mock.method(AbortSignal, "timeout", (ms: number) => {
if (ms !== answerMs) return realTimeout(ms);
const deadline = new AbortController();
deadlines.push(deadline);
return deadline.signal;
});
let releaseFinish!: () => void;
const waitBeforeFinish = new Promise<void>((resolve) => { releaseFinish = resolve; });
const head = ANSWER.slice(0, ANSWER.indexOf(`## ${REPORT_HEADING.timing}`));
const result = await run([
calcCall(),
{ ...lookupCall("varga:D60"), delayMs: 40 },
{ parts: pieces(ANSWER, 6), finish: "stop", delayMs: 25 },
], { answerMs: 300 });
lookupCall("varga:D60"),
{ parts: pieces(head), finish: "stop", waitBeforeFinish },
], {
answerMs,
onAnswerDelta: (text) => {
if (text.trim()) {
deadlines[0]?.abort(new DOMException("Answer deadline elapsed", "TimeoutError"));
releaseFinish();
}
},
});
assert.deepEqual(result.terminal.map((event) => `${event.type}:${event.code ?? ""}`), ["run.failed:answer_truncated"]);
assert.equal(result.charges, 0);
assert.equal(result.answerSignals.length, 1, "the answer clock started once");
assert.ok(result.state.steps.some((step) => step.kind === "abort" && step.name === "compose-abort"));
assert.equal(result.state.evidenceLookupCallCount, 1);
assert.equal(deadlines.length, 1, "the lookup did not create another answer deadline");
assert.equal(result.answerSignals[0], deadlines[0].signal);
assert.ok(result.answer.trim(), "the deadline elapsed after the client received text");
});
test("a lookup in the answer phase is not cut by the tool phase's deadline", async () => {
@@ -25,7 +25,7 @@ import {
} from "../src/mastra/consultation-tools.ts";
export type Part = { text?: string; tool?: { name: string; input: Record<string, unknown> } };
export type Turn = { parts: Part[]; finish: string; delayMs?: number };
export type Turn = { parts: Part[]; finish: string; delayMs?: number; waitBeforeFinish?: Promise<void> };
/** A LanguageModelV2 that plays `turns` in order and records every prompt. */
export function scriptedModel(turns: Turn[]) {
@@ -85,6 +85,13 @@ export function scriptedModel(turns: Turn[]) {
}
}
if (textOpen) controller.enqueue({ type: "text-end", id: `t${call}` });
if (turn.waitBeforeFinish) {
await turn.waitBeforeFinish;
if (signal?.aborted) {
controller.error(signal.reason);
return;
}
}
controller.enqueue({
type: "finish",
finishReason: turn.finish,
@@ -133,6 +140,8 @@ export async function runNatalAgent(
question?: string;
toolPhaseMs?: number;
answerMs?: number;
/** Observe text as the client receives it, before the model finishes. */
onAnswerDelta?: (text: string) => void;
/** The bound subject's server chart; defaults to the public AA chart without a gender. */
serverChart?: Record<string, unknown>;
/** The shape instruction the user turn carries; defaults to the first-turn natal shape. */
@@ -212,8 +221,19 @@ export async function runNatalAgent(
onError: (error) => { errored = error; },
});
const events: Array<{ type: string; code?: string; text?: string; label?: string; phase?: string; receipt?: unknown }> = [];
const parser = createNdjsonParser((event) => events.push(event as never));
parser.finish(await response.text());
const parser = createNdjsonParser((event) => {
events.push(event as never);
if (event.type === "answer.delta") options.onAnswerDelta?.(event.text);
});
if (options.onAnswerDelta) {
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
parser.push(chunk.value);
}
parser.finish();
} else {
parser.finish(await response.text());
}
const answer = events.filter((event) => event.type === "answer.delta").map((event) => event.text ?? "").join("");
const terminal = events.filter((event) => event.type === "run.completed" || event.type === "run.failed");
return {
File diff suppressed because one or more lines are too long
@@ -6,7 +6,8 @@ import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, reportReadingSchema
import { buildReportExportBlocks, joinExportBlocks, presetSelection } from "../src/lib/report-export-blocks";
const fixture = (name: string) => readFileSync(new URL(`./fixtures/report-export-20261111/${name}`, import.meta.url), "utf8");
const reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json")));
// The fixture is a stored version-2 reading (plain paragraphs); #192 keeps it readable.
const reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json"))) as Extract<ReturnType<typeof reportReadingSchema.parse>, { version: 2 }>;
const synthetic = "# 个人报告\n\n这段摘要随报告一起导出。\n\n## 引擎章节\n\n这是计算资料。\n";
for (const [name, markdown] of [["stored reading", synthetic], ["public report opening", fixture("jobs-head-zh.md")]]) {
@@ -11,7 +11,14 @@ import test from "node:test";
import { buildAppendix, rangeView } from "../src/lib/annual-report-generate.ts";
import type { AnnualBirthTime } from "../src/lib/annual-report-shared.ts";
import { REPORT_READING_SYSTEM } from "../src/lib/personal-report-reading.ts";
import { readFileSync as readContract } from "node:fs";
// #192: the report writer's product rules moved to references/product_runtime_contract.md (block report-reading).
const contractSource = readContract(new URL("../../references/product_runtime_contract.md", import.meta.url), "utf8");
const REPORT_READING_RULES = contractSource.slice(
contractSource.indexOf("<!-- product-block:report-reading -->"),
contractSource.indexOf("<!-- /product-block:report-reading -->"),
);
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
const BANNED = ["联网", "外部资料", "外部核验", "MEVG", "真实案例", "Real Case", "Global Web"];
@@ -73,7 +80,10 @@ test("synastry technical basis never shows web-verification wording", () => {
});
test("personal report writer prompt never asks for web-verification wording", () => {
assertNoWebWording(REPORT_READING_SYSTEM, "个人报告写作提示词");
// 置信度封顶仍在,只是理由改成「没有用户经历可对照」。
assert.match(REPORT_READING_SYSTEM, /置信度最高为 B/);
assertNoWebWording(REPORT_READING_RULES.replace(/^<!--.*-->$/gm, ""), "个人报告写作提示词");
// 原值:assert.match(REPORT_READING_SYSTEM, /置信度最高为 B/)(「没有用户经历可对照,全篇置信度最高为 B」)
// 新值:不再有全篇封顶句,等级由后端按每条结论给上限(report_evidence_grade_v1)
// 原因:占星师 2026-11-12(8aaf6099 §5.2):「全报告不采用“没有用户经历所以最高B”」
assert.doesNotMatch(REPORT_READING_RULES, /置信度最高为 B/);
assert.match(REPORT_READING_RULES, /evidence_grades/);
});
+183 -57
View File
@@ -17,9 +17,9 @@ import { hashLongformMarkdown } from "../src/lib/personal-report-longform-append
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
import {
REPORT_READING_SECTIONS,
REPORT_READING_SYSTEM,
acceptReportReadingDraft,
writeReportReading,
type ReadingSkillMeta,
type ReportReadingWriter,
} from "../src/lib/personal-report-reading";
import { resolveReportRead } from "../src/lib/personal-report-route-core";
@@ -34,45 +34,83 @@ const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
function draft(overrides: Partial<Record<string, string[]>> = {}) {
// #192 原值:每段是字符串;新值:每段是 { text, claim_type, grade },六个领域节必须带结论类型与等级
// 原因:占星师 2026-11-12(8aaf6099 §1.3–1.4):等级逐结论标,上限由后端给
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,
};
function draft(overrides: Partial<Record<string, string[]>> = {}, grade = "C") {
return {
sections: REPORT_READING_SECTIONS.map((section) => ({
id: section.id,
// 原值:[PARAGRAPH](一段约 150 字,八节合计过 900 字下限)
// 新值:[PARAGRAPH, PARAGRAPH](九节合计过 1500 字下限)
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,证据在前的专业写法篇幅更长
paragraphs: overrides[section.id] ?? [PARAGRAPH, PARAGRAPH],
paragraphs: (overrides[section.id] ?? [PARAGRAPH, PARAGRAPH]).map((text) => ({
text,
claim_type: SECTION_CLAIM[section.id] ?? null,
grade: SECTION_CLAIM[section.id] ? grade : null,
})),
})),
};
}
// 原值:schema "report-reading-facts-v1",无 functional
// 新值:report-reading-facts-v2,带功能吉凶层(契约必备)
// 原因:产品 2026-11-07 占星师方案 item 10:统一 reading_facts 契约
const SKILL: ReadingSkillMeta = { name: "jyotish-vedic-astrology", version: "6.9.15", loaded: [{ path: "SKILL.md", sha256: "a".repeat(64) }] };
function claim(cap: string, sections: string[]) {
return { cap, sections, required: [], checked: [], must_disclose: [], parameter_sensitive: [] };
}
// 原值:report-reading-facts-v2
// 新值:report-reading-facts-v3
// 原因:按 #201 事实包版本更新写作和 worker 的测试输入。
const FACTS = {
schema: "report-reading-facts-v2",
schema: "report-reading-facts-v3",
minute_confirmed: true,
functional: { functional_benefics: ["Saturn"], functional_malefics: ["Jupiter"], natural_vs_functional_conflicts: [] },
ascendant: { sign: "Taurus", lord: "Venus" },
planets: { Sun: { d1_sign: "Pisces", d1_house: 11 } },
domains: { career: { varga: "D10", houses: { "10": { lord: "Saturn" } } } },
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
evidence_grades: {
contract: "report_evidence_grade_v1",
claims: {
character_structure: claim("C", ["overview", "character"]),
career_promise: claim("C", ["overview", "career"]),
wealth_earning: claim("C", ["overview", "wealth"]),
wealth_retention: claim("C", ["overview", "wealth"]),
relationship_structure: claim("C", ["overview", "love"]),
health_pressure: claim("C", ["overview", "health"]),
health_obstruction: claim("D", ["health"]),
timing_window: claim("C", ["overview", "career", "wealth", "love", "health", "now"]),
},
},
};
const accept = (value: unknown) => acceptReportReadingDraft(value, FACTS, SKILL);
function withheldIds(value: unknown) {
const accepted = accept(value);
assert.ok(accepted.ok, JSON.stringify(accepted));
return accepted.reading.sections.flatMap((section) => "status" in section && section.status === "withheld" ? [`${section.id}:${section.reason}`] : []);
}
// 原值:八节,最后一节「几条建议」,存为 version 1
// 新值:九节,加「依据与边界」,存为 version 2
// 原因:产品 2026-11-07 占星师方案:依据、置信度与阻塞项单列(interpretation_status_contract 的报告顺序)
test("a clean nine-part draft becomes the stored reading with our titles", () => {
const accepted = acceptReportReadingDraft(draft());
const accepted = accept(draft());
assert.ok(accepted.ok);
assert.equal(accepted.reading.version, 2);
// #192 原值:version 2;新值:version 3(逐段等级、逐节状态、加载清单)
assert.equal(accepted.reading.version, 3);
assert.equal(accepted.withheld, 0);
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议", "依据与边界"]);
assert.equal(accepted.reading.language, "zh");
});
test("「不一定会」 is not a fate claim and is accepted", () => {
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
assert.equal(accepted.ok, true);
assert.deepEqual(withheldIds(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] })), []);
});
// 原值:段落里出现英文字母即以 latin 退稿
@@ -80,68 +118,143 @@ 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。`;
const accepted = acceptReportReadingDraft(draft({ career: [professional, PARAGRAPH] }));
assert.equal(accepted.ok, true);
const snake = acceptReportReadingDraft(draft({ career: [`${PARAGRAPH}parameter_sensitive`] }));
assert.equal(!snake.ok && snake.reason, "field_name");
assert.deepEqual(withheldIds(draft({ career: [professional, PARAGRAPH] })), []);
// #192 原值:整份退稿 field_name;新值:只有这一节不显示(产品 2026-11-12)
assert.deepEqual(withheldIds(draft({ career: [`${PARAGRAPH}parameter_sensitive`] })), ["career:field_name"]);
});
test("drafts with field names, fate, disease, investment or stacked disclaimers are rejected", () => {
const cases: Array<[string, unknown]> = [
["field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
["banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
["banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
["banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
["banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
["banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
["disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
// #192 原值:任一段违规即整份退稿;新值:只把违规的那一节换成「这一节没有通过数据核对,暂不显示。」,
// 结构错误和全篇字数越界仍整份退稿。原因:产品负责人 2026-11-12 选「该节显示一行说明」;占星师 §6 P2「不悄悄删除解读」。
test("a section with field names, fate, disease, investment or stacked disclaimers is withheld on its own", () => {
const sections: Array<[string, unknown]> = [
["career:field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
["love:banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
["now:banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
["health:banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
["wealth:banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
["overview:banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
["overview:banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
["love:banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
// #192 eval 2026-11-12: the draft wrote that web and case calibration were not done (BUG-1794: backstage only).
["basis:banned_phrase", draft({ basis: [`${PARAGRAPH}全球网络资料采集与真实案例校准在本次都未完成。`] })],
["health:disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
// 原值:PARAGRAPH.repeat(5)(690 字,超过旧上限 420);新值:repeat(6)(828 字,超过新上限 700)
// 原因:产品 2026-11-07 占星师方案:段落上限随专业写法放宽
["career:too_long", draft({ career: [PARAGRAPH.repeat(6)] })],
];
for (const [expected, value] of sections) assert.deepEqual(withheldIds(value), [expected]);
const whole: Array<[string, unknown]> = [
["shape", { sections: draft().sections.slice(0, 8) }],
["shape", { sections: [...draft().sections].reverse() }],
["too_short", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, ["很短的一句。"]])))],
// 原值:PARAGRAPH.repeat(5)(690 字,超过旧上限 420);新值:repeat(6)(828 字,超过新上限 700)
// 原因:产品 2026-11-07 占星师方案:段落上限随专业写法放宽
["too_long", draft({ career: [PARAGRAPH.repeat(6)] })],
["too_long", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, Array(6).fill(PARAGRAPH.repeat(4))])))],
["all_sections_withheld", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, [`${PARAGRAPH}你注定晚婚。`]])))],
];
for (const [reason, value] of cases) {
const accepted = acceptReportReadingDraft(value);
assert.equal(accepted.ok, false, reason);
for (const [reason, value] of whole) {
const accepted = accept(value);
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
}
});
// 原值:要求 C1 白话规则(「因为……」、「讲时间时最多说三件」)
// 新值:要求她的 skill 写法:证据在前、领域分盘组合、功能吉凶冲突、双轨应期带日期、行运、A/B/C、状态标签
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按专业写法
test("the prompt carries the astrologer's professional rules and the red lines", () => {
// #192:占星师 2026-11-12(8aaf6099 §5.2、§6):等级不得高于后端上限;声称无阻塞要与依赖一致;尊贵度不得自改。
test("grades above the backend ceiling, false no-blocker claims and invented dignities withhold the section", () => {
// Six graded sections withheld leave the three others; give those enough text to clear the 1,500-character floor.
const long = { overview: Array(6).fill(PARAGRAPH), advice: Array(6).fill(PARAGRAPH), basis: Array(6).fill(PARAGRAPH) };
assert.deepEqual(withheldIds(draft(long, "B")), [
"character:grade_above_cap", "career:grade_above_cap", "wealth:grade_above_cap",
"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"]);
const wrongSection = draft();
(wrongSection.sections[2]!.paragraphs[0] as { claim_type: string | null }).claim_type = "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" });
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"]);
assert.deepEqual(withheldIds(draft({ basis: [`${PARAGRAPH}本报告无关键层阻塞。`] })), ["basis:false_no_blocker"]);
const dignityFacts = { ...FACTS, domains: { wealth: { varga_charts: { D2: { Jupiter: { sign: "Cancer", dignity: "Exalted", dignity_zh: "入旺" } } } } } };
const own = acceptReportReadingDraft(draft({ wealth: [`${PARAGRAPH}D2 木星在巨蟹入庙。`, PARAGRAPH] }), dignityFacts, SKILL);
assert.ok(own.ok && own.reading.sections.some((section) => "status" in section && section.status === "withheld" && section.reason === "dignity_mismatch"));
const exalted = acceptReportReadingDraft(draft({ wealth: [`${PARAGRAPH}D2 木星在巨蟹入旺。`, PARAGRAPH] }), dignityFacts, SKILL);
assert.ok(exalted.ok && exalted.withheld === 0);
// A dignity word written right before the next planet is that planet's (DeepSeek eval 2026-11-12, chart A).
const listFacts = { ...FACTS, planets: { Jupiter: { d1_dignity_zh: "中性" }, Venus: { d1_dignity_zh: "落陷" } } };
const listed = acceptReportReadingDraft(draft({ health: [`${PARAGRAPH}第十二宫聚集木星、落陷金星与土星。`, PARAGRAPH] }), listFacts, SKILL);
assert.ok(listed.ok && listed.withheld === 0, JSON.stringify(listed.ok && listed.reading.sections.find((s) => "status" in s)));
const wrongOwner = acceptReportReadingDraft(draft({ health: [`${PARAGRAPH}木星落陷,金星也在这里。`, PARAGRAPH] }), listFacts, SKILL);
assert.ok(wrongOwner.ok && wrongOwner.withheld === 1);
// A cancelled debilitation in the facts (「落陷取消」) allows writing 「落陷」 (DeepSeek eval 2026-11-12, public chart B).
const cancelledFacts = { ...FACTS, planets: { Jupiter: { d1_dignity_zh: "落陷取消" } } };
const cancelled = acceptReportReadingDraft(draft({ overview: [`${PARAGRAPH}木星在第一宫摩羯落陷但成立落陷取消。`, PARAGRAPH] }), cancelledFacts, SKILL);
assert.ok(cancelled.ok && cancelled.withheld === 0);
});
// BUG-1852: her 8aaf6099 §6 P1 — Indu written as "references 未找到" although the engine computed it.
test("calling a computed point unsourced or uncomputed withholds that section only", () => {
const computedFacts = {
...FACTS,
domains: {
wealth: { participation: [{ id: "Indu", state: "not_in_domain_route", computed: true, value: "Gemini" }] },
health: { participation: [{ id: "Badhaka", state: "executed", value: { house: 11, lord: "Venus" } },
{ id: "Gulika", state: "not_in_domain_route" }] },
},
};
const verdict = (basis: string) => {
const accepted = acceptReportReadingDraft(draft({ basis: [`${PARAGRAPH}${basis}`, PARAGRAPH] }), computedFacts, SKILL);
assert.ok(accepted.ok, JSON.stringify(accepted));
return accepted.reading.sections.flatMap((section) => "status" in section && section.status === "withheld" ? [`${section.id}:${section.reason}`] : []);
};
assert.deepEqual(verdict("Vighati、Indu Lagna、Gulika 在参考资料里没有对应条目,故不参与。"), ["basis:false_missing_layer"]);
assert.deepEqual(verdict("本次所需的 Badhaka 计算没有完成。"), ["basis:false_missing_layer"]);
// Stating it accurately passes; so does saying an uncomputed point was not computed.
assert.deepEqual(verdict("Indu Lagna 已算出,在双子,本报告不用它下结论。"), []);
assert.deepEqual(verdict("Gulika 没有计算。"), []);
});
// #192 原值:REPORT_READING_SYSTEM(TypeScript 里的产品提示,九节 + 节尾「置信度:A/B/C」+「全篇最高 B」)
// 新值:她的 SKILL.md 与路由 references 原文,加 references/product_runtime_contract.md 的 report-reading 块
// 原因:产品负责人 2026-11-11 决定 2(报告按她的 skill 写);占星师 2026-11-12 §5、§7.3
const CONTRACT = readFileSync(new URL("../../references/product_runtime_contract.md", import.meta.url), "utf8");
const RULES = CONTRACT.slice(CONTRACT.indexOf("<!-- product-block:report-reading -->"), CONTRACT.indexOf("<!-- /product-block:report-reading -->"));
test("the report rules follow her 2026-11-12 contract and keep the red lines", () => {
for (const rule of [
"证据在前,判断在后", "D2、D11、A2、A11", "D10、A10、AmK、AK", "D9、UL、A7、DK", "能不能挣到", "能不能留下",
"自然吉凶和功能吉凶不一致", "Vimshottari(大运、小运、细运,起止日期)", "Narayana(当前大运与小运", "两面都写",
"木星、土星、罗睺、计都", "置信度:A/B/C", "「阻塞」", "「参数敏感」", "「待用户经历核验」", "候选",
"宫位第一次出现时", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "basis",
"current_advice_allowed", "本命承诺 → 激活 → 落地 → 应期", "能不能挣到", "能不能留下", "心动、成对还是法律婚",
"不从 6、8、12 宫直接推出", "observation_date", "一般建议", "evidence_grades.claims", "must_disclose",
"没有关键阻塞", "本身不是冲突", "checked_houses", "dignity_zh", "不写病名", "不给投资指令",
"「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "{{CLAIM_TYPES}}",
]) {
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
assert.ok(RULES.includes(rule), rule);
}
for (const gone of ["讲时间时最多说三件", "力量只说强、中、弱", "不写任何英文字母"]) {
assert.equal(REPORT_READING_SYSTEM.includes(gone), false, gone);
for (const gone of ["置信度最高为 B", "置信度:A/B/C", "Yoga 只能写成「候选」"]) {
assert.equal(RULES.includes(gone), false, gone);
}
const loader = readFileSync(new URL("../src/mastra/report-reading-skill.ts", import.meta.url), "utf8");
for (const file of ["SKILL.md", "references/strict-workflow-router.md", "references/event_judgment_skeleton.md", "references/event_judgment_career.md"]) {
assert.ok(loader.includes(file), file);
}
});
const PROMPT = { system: "system", skill: SKILL };
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
return async (input) => {
return Object.assign(async (input: Parameters<ReportReadingWriter>[0]) => {
calls.push(input.user);
const body = bodies.shift();
if (body instanceof Error) throw body;
return { body, inputTokens: 1000, outputTokens: 500 };
};
}, { prompt: PROMPT });
}
test("one retry after a rejected draft; tokens of both attempts are counted", async () => {
// #192 原值:第一稿被退才重试;新值:第一稿有不显示的节也重试,取不显示节更少的一稿
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);
assert.ok(outcome.reading && outcome.reading.version === 3);
assert.ok(outcome.reading.sections.every((section) => "status" in section && section.status === "ok"));
assert.deepEqual(outcome.reading.skill, SKILL);
assert.equal(outcome.inputTokens, 2000);
assert.equal(outcome.outputTokens, 1000);
assert.equal(calls.length, 2);
@@ -158,18 +271,21 @@ test("no facts, a failing model or a hung model all leave the report without a r
assert.equal(failed.reading, null);
assert.equal(failed.unavailable, "model_failed");
// A writer that ignores its abort signal still loses to the timeout.
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: Object.assign(() => new Promise<never>(() => undefined), { prompt: PROMPT }) });
assert.equal(hung.reading, null);
assert.equal(hung.unavailable, "model_timeout");
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
assert.equal(rejected.unavailable, "rejected_banned_phrase");
// #192 原值:两稿都含禁用词即整份不可用(rejected_banned_phrase);新值:只有那一节不显示,结构错误才整份不可用
const partial = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
assert.ok(partial.reading?.sections.some((section) => section.id === "health" && "status" in section && section.status === "withheld"));
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([{ sections: [] }, { sections: [] }]) });
assert.equal(rejected.unavailable, "rejected_shape");
});
test("a cancelled job still cancels; the reading never swallows the abort", async () => {
const controller = new AbortController();
const pending = writeReportReading({
facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
write: Object.assign(({ signal }: Parameters<ReportReadingWriter>[0]) => new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))), { prompt: PROMPT }),
});
controller.abort(new Error("job_cancelled"));
await assert.rejects(pending, /job_cancelled/);
@@ -195,13 +311,16 @@ function snapshot(extra: Record<string, unknown> = {}) {
}
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
const reading = (accept(draft()) as { reading: unknown }).reading;
assert.equal(validateLongformSnapshot(snapshot(), identity).version, 2);
const stored = validateLongformSnapshot(snapshot({ reading }), identity);
// 原值:8 节;新值:9 节,且 2026-11-07 前存的 version 1 八节解读仍能读出
// 原因:产品 2026-11-07 占星师方案:加「依据与边界」一节
assert.equal(stored.version === 2 && stored.reading?.sections.length, 9);
const v1 = { version: 1, language: "zh", sections: (reading as { sections: Array<{ id: string; title: string; paragraphs: string[] }> }).sections.slice(0, 8) };
// #192:v1、v2 存的是字符串段落,照常能读
const plain = REPORT_READING_SECTIONS.map((section) => ({ id: section.id, title: section.title, paragraphs: [PARAGRAPH] }));
const v1 = { version: 1, language: "zh", sections: plain.slice(0, 8) };
assert.equal(validateLongformSnapshot(snapshot({ reading: { version: 2, language: "zh", sections: plain } }), identity).version, 2);
const older = validateLongformSnapshot(snapshot({ reading: v1 }), identity);
assert.equal(older.version === 2 && older.reading?.sections.length, 8);
assert.throws(() => validateLongformSnapshot(snapshot({ reading: { ...v1, version: 2 } }), identity));
@@ -232,7 +351,7 @@ async function read(loadLongformReading?: Parameters<typeof resolveReportRead>[0
}
test("the read API returns the reading when there is one and never fails the report over it", async () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
const reading = (accept(draft()) as { reading: unknown }).reading;
const ready = await read(async () => reading as never);
assert.equal(ready.status, 200);
assert.deepEqual(ready.body.reading, reading);
@@ -245,7 +364,7 @@ test("the read API returns the reading when there is one and never fails the rep
});
test("the page keeps a reading only in the stored shape and shows it before the engine report in Chinese only", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: { sections: unknown[] } }).reading;
const reading = (accept(draft()) as { reading: { sections: unknown[] } }).reading;
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
report: { id: identity.reportId, status: "ready", createdAt: "2026-10-27T00:00:00.000Z" },
longformMarkdown: zhReader.markdown, ...extra,
@@ -269,6 +388,13 @@ test("the page keeps a reading only in the stored shape and shows it before the
assert.ok(zh.indexOf('id="report-reading-overview"') > readingAt, "each reading section is an anchor");
assert.ok(readingAt > zh.indexOf("印度占星星盘报告") && readingAt < basisAt && basisAt < engineAt, zh.slice(0, 400));
for (const title of ["总评", "现在这几年", "几条建议", "依据与边界"]) assert.ok(zh.includes(`<h3>${title}</h3>`), title);
// #192(产品 2026-11-12 原型方案 B):领域节每段下面一行灰字,写等级和含义;总评等不分级的节没有
assert.ok(zh.includes("证据等级 C:有星盘依据,还没有用真实案例校准过"), "grade line");
assert.match(zh, /<p class="personal-report-evidence-grade">证据等级 C:/, "a plain line, no tap to open");
const withheld = (accept(draft({ love: [`${PARAGRAPH}你注定晚婚。`] })) as { reading: unknown }).reading;
const shown = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={withheld as never} language="zh" />);
assert.ok(shown.includes("这一节没有通过数据核对,暂不显示。"));
assert.ok(shown.includes("<h3>感情</h3>"));
const en = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="en" />);
assert.equal(en.includes("report-reading"), false);
assert.equal(en.includes("计算依据"), false);
@@ -321,10 +447,10 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
async upsert(row) { rows.set(table, structuredClone(row)); return { error: null }; },
}; }};
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
const writeReading = mode === "no-writer" ? undefined : async () => {
const writeReading = mode === "no-writer" ? undefined : Object.assign(async () => {
if (mode === "model-down") throw new Error("model down");
return { body: draft, inputTokens: 1200, outputTokens: 800 };
};
}, { prompt: { system: "system", skill: ${JSON.stringify(SKILL)} } });
const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"),
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
...(writeReading ? { writeReading } : {}) });
+1 -1
View File
@@ -20,7 +20,7 @@ Profiles record their role separately from their identifier. Mudda records the l
## Validation and release manifest
Run `python -m scripts.preset_evidence.library --her-export <source-directory>` to check the schema, receipt, source hashes, normalized excerpts, and the bound repository functions and tests. For the source directory, use an exact Git checkout or an export with source revision and file hashes from that checkout. A plain export of the same pinned files can be verified against the previously generated manifest. Add `--write-verified` to publish `VERIFIED.json` after a full check. `--skip-her-export` runs development checks and cannot write the manifest.
Run `python -m scripts.preset_evidence.library --her-export <source-directory>` to check the schema, receipt, source hashes, normalized excerpts, and the bound repository functions and tests. Pass either `--her-export` or `--skip-her-export` explicitly. Use a Git checkout at the source revision pinned in `VERIFIED.json` or a plain export with the same source-file hashes. For source-backed pytest checks, set `PRESET_EVIDENCE_HER_EXPORT` to that directory. The checks skip when the variable is unset and fail when the supplied checkout or export does not match the pin. Add `--write-verified` to publish `VERIFIED.json` after a full check. `--skip-her-export` runs development checks and cannot write the manifest.
`VERIFIED.json` records the source revision, hashes of every resolved source dependency, validator and matcher versions, and generation time. The library content hash covers the bytes of `schema.json` and every entry file in sorted path order. Git keeps their LF line endings across platforms. The receipt has its own pinned hash. An absolute path, parent traversal, or symlink outside its declared root fails validation.
+32
View File
@@ -248,3 +248,35 @@ evidence_card.backstage records, for the backend only, that global web evidence
- 裁定 7 两个状态轴(BUG-1753):审计表每行分开写 Calculation(`calculation_status`:executed / partial / blocked / not_applicable)与 Interpretation(`interpretation_status`:她 interpretation_status_contract 的 primary / supporting / conditional / observation_only / blocked,另加产品的 `not_used`)。原值单一 Status 里的「Used(候选)」改为 executed + conditional;能算但方法有分歧不写 blocked。前端行结构(`techniqueAuditRowSchema`)、表格解析、技法表组件与年报附录同步改为两轴;旧的单值行按 executed → executed + primary、candidate → executed + conditional、blocked → blocked + blocked、not_applicable → not_applicable + not_used 读入。
- 裁定 8 外部验证四行(BUG-1754):行名照她写的「本地计算」「预置来源匹配」「实时联网」「真实案例校准」,不加「MEVG ·」前缀;值依次为 executed(或 partial / blocked)、不足(产品没有预置来源库;有库并匹配才写 matched)、blocked、按实际情况(本轮 blocked,「未完成」),不默认完成。 **BUG-1794 起(产品 2026-11-08 决定)**:这四行不再写进回答的审计表,只留在后台回执,见「F」6c。
- 裁定 6 F 节(BUG-1755、BUG-1756、BUG-1757):见「F」第 1–6 条。白话可以减少术语,不能减少信息:删「最多两条依据」「两个及以上受冲才写压力」「白话不主动提核验缺口」「限制放末尾」;方法分歧时写技法名;受冲条数留在后台,用户需要理解冲突时写实际来源。专业模式与白话模式两套 VISIBLE VOICE 都改。
## G. 个人报告「解读」(report-reading,#192,2026-11-12)
依据:占星师 2026-11-12 审核回复(她的仓库 `8aaf6099`,`handoff/2026-11-12/preset_q3/README.md` 第一、五、六、七节)。报告「解读」先绑定她的 SKILL.md 全文与相关 references(见 `frontend/src/mastra/report-reading-skill.ts`,实际加载清单与哈希存进每份解读),再附本块。本块是产品对报告体裁的规定,不是她的方法;与她历史文件里的分级说法冲突时,按她 11-12 交接合同 `report_evidence_grade_v1` 执行(她 §7.3)。原值:`frontend/src/lib/personal-report-reading.ts` 的 `REPORT_READING_SYSTEM`(2026-11-07 方案,九节 + 节尾 A/B/C + 「没有用户经历所以最高 B」)。
**产品计算,待占星师审核(BUG-1853,2026-11-12):Badhaka。** 她的引擎(`8aaf6099`)没有本命 Badhaka 模块,健康领域清单却列了它。产品在 `scripts/report_render/badhaka_facts.py` 里按 `references/badhaka-obstacle-planet-guide.md` §二(与她仓库同名文件逐字相同)从已算好的 D1 位置给出 Badhaka 宫、星座、宫主及其落点、宫内行星,只给位置、不下结论,记录里带 `status: product_computation_pending_astrologer_review`。它不改她的 `jyotish_engine.py`、`yoga_engine.py`(只调用后者的 `badhaka_house`)。报告解读和聊天健康证据卡都读这条记录。她的路由 §7 本身没有列 Badhaka;是否保留为健康必查项、用哪条规则,等她审核。
<!-- product-block:report-reading -->
<!-- 产品规则,不是占星师的方法:R-01 输入边界。 -->
你在给一份个人星盘报告写开头的「解读」。用户消息里的盘面事实(report-reading-facts-v3)是引擎已经算好的全部材料,只用这些,不编数据,不另起算法,不凭印象重算尊贵度、宫位或日期。事实里没有的层就当没有。
<!-- 产品规则,不是占星师的方法:R-02 报告模式。她 §6 P0:已故的公开人物不能给当前建议。 -->
先看 report_mode 和 current_advice_allowed。current_advice_allowed 为 false 时,这是历史人物的计算回放:不给当前的健康、职业、感情或行动建议,「现在这几年」只写观察日期所在的运程如何排列,「几条建议」写成对这张盘的阅读要点;不要按真人身份或生平去写。
<!-- 产品规则,不是占星师的方法:R-03 九节职责。她 §5.1。 -->
九节固定,按顺序:总评、性格、事业、财富、感情、健康、现在这几年、几条建议、依据与边界。
- 总评:先用三到六句回答这张盘最突出的地方,再简短给依据。原始数值和逐项技法放在后面的计算依据里,正文不必每段先列一长串依据,但用到的数值必须照事实原值写。
- 性格:只写静态结构(上升、月亮、上升主等),不要求每项性格都配大运。
- 事业、财富、感情、健康:先结论,再简短依据。讲时间时按「本命承诺 → 激活 → 落地 → 应期」推理;落地的依据不足时,只写条件窗口,不说已经落地。财富分开写「能不能挣到」和「能不能留下」。感情先分清是心动、成对还是法律婚,不把所有关系需求都当成婚姻。
- 健康:只写有依据的传统条件和要多留意的时段,不从 6、8、12 宫直接推出免疫、睡眠这类具体身体事实,不写病名。D30 能不能用看 birth_precision 和 evidence_grades,不只看 minute_confirmed。
- 现在这几年:写明实际覆盖哪几年。行运只是 observation_date 那一天的位置,不外推成几个月或几年的结论;时间粒度由证据决定。
- 几条建议:两到四条,每条只能落在前面已经有依据的主题上。只是一般性的建议,就写明「一般建议」,不装成星盘算出来的结论;不从数值门槛推出具体作息时刻、贷款或资产安排。
- 依据与边界:功能吉星、功能凶星及与自然吉凶的冲突;实际用到的技法;没有使用的层及原因(按 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 里的缺口和参数敏感的层要在相关段落里说出来。只有某类结论的必需依据全部可用时,才能说它「没有关键阻塞」。
<!-- 产品规则,不是占星师的方法:R-05 不把不同体系的不同当冲突。她 §5.1、§6 P1。 -->
两套大运(Vimshottari、Narayana)的主星或星座不同,本身不是冲突;同名主星也不自动算一致。只有对应同一件事、同一段时间、同一个判断方向时,才比较是否一致。KP 与整宫制结果不同,写成方法差异。Double Transit 只对 checked_houses 里的宫位有结论,不推到别的宫或别的领域。
<!-- 产品规则,不是占星师的方法:R-06 格局与尊贵度。她 §5.1、§6 P1。 -->
格局按 yogas 每一行的形成状态写:满足了来源的形成条件就可以说形成,现实里是否应验另说;不把未经现实验证写成未形成。尊贵度照 dignity_zh 原词写(入旺、入庙、落陷……),不自己翻译。
<!-- 产品规则,不是占星师的方法: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。
<!-- /product-block:report-reading -->
+28 -12
View File
@@ -172,6 +172,25 @@ def _planet_base(view: dict, name: str) -> str | None:
return None
def _badhaka(view: dict) -> dict[str, Any]:
"""BUG-1853: product computation from D1 positions (report_render/badhaka_facts.py).
Her engine has no natal Badhaka module; the record says it is pending her review.
"""
from report_render.badhaka_facts import badhaka_facts
asc = dig(view, "core_chart.houses.house_1.cusp_sign") or dig(view, "worksheets.d1_rasi_bhava.houses.house_1.cusp_sign")
planets = {}
for name in PLANETS:
base = _planet_base(view, name)
if base:
planets[name] = dig(view, base)
record = badhaka_facts(asc, planets)
if record is None:
return _gap("core_chart.houses.house_1.cusp_sign")
return {"status": "value", "path": "product:report_render.badhaka_facts", "value": record, "check": "product_computation"}
def _functional_role(view: dict, name: str) -> dict[str, Any]:
base = "worksheets.strengths_and_scores.functional_benefic_malefic"
if name in ("Rahu", "Ketu"):
@@ -486,7 +505,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
("AK", "sourced", f"{CAREER} §2 Jaimini AmK / AK / Karakamsha"),
("Karakamsha", "sourced", f"{CAREER} §2 Karakamsha"),
("Ghati", "sourced", f"{DEEP} Ghati Lagna 权力指标"),
("Vighati", "blocked", "references/ 未找到 Vighati 条目"),
("Vighati", "blocked", f"{ROUTER} §3 必查模块未列 Vighati Lagna;本领域不据此判断"),
("natural:Sun", "sourced", f"{CAREER} §2 Sun"),
("natural:Saturn", "sourced", f"{CAREER} §2 Saturn"),
("natural:Mercury", "sourced", f"{CAREER} §2 Mercury"),
@@ -502,7 +521,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
("A11", "sourced", f"{WEALTH} §3 A2 / A11 / A10"),
("A10", "sourced", f"{WEALTH} §3 A2 / A11 / A10"),
("Hora", "sourced", f"{DEEP} Hora Lagna 财富指标"),
("Indu", "blocked", "references/ 未找到 Indu Lagna 财富条目"),
("Indu", "blocked", f"{ROUTER} §5 必查模块与 {WEALTH} §3 未列 Indu Lagna;本领域不据此判断"),
("Sree", "sourced", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna"),
("AL2", "blocked", f"{WEALTH} §3 未列出从 AL 起第 2 宫"),
("AL11", "blocked", f"{WEALTH} §3 未列出从 AL 起第 11 宫"),
@@ -532,7 +551,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
("D8", "sourced", f"{ROUTER} §7 D8"),
("D30", "sourced", f"{ROUTER} §7 D30"),
("drekkana22", "sourced", f"{SHODASA} 第22Drekkana与寿限"),
("navamsa64", "blocked", "references/ 未找到第 64 个 Navamsa 条目"),
("navamsa64", "blocked", f"{ROUTER} §7 必查模块未列第 64 个 Navamsa;本领域不据此判断"),
("Badhaka", "sourced", BADHAKA),
("Gulika", "blocked", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"),
("Mandi", "blocked", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"),
@@ -563,7 +582,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
],
"education": [
("D24", "sourced", f"{ROUTER} §2 D24 for education"),
("D5", "blocked", "references/ 未找到把 D5 标成学业分盘的段落"),
("D5", "blocked", f"{ROUTER} §2 未把 D5 列为学业分盘;本领域不据此判断"),
("natural:Mercury", "sourced", f"{MAPPING} 教育 水星"),
("natural:Jupiter", "sourced", f"{MAPPING} 教育 木星"),
("house:4", "sourced", f"{MAPPING} 教育(基础教育)4宫"),
@@ -680,7 +699,7 @@ def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict
_sourced("AK", f"{CAREER} §2 Jaimini AmK / AK / Karakamsha", _karaka(view, "7", "Atmakaraka")),
_sourced("Karakamsha", f"{CAREER} §2 Karakamsha", _leaf(view, "worksheets.advanced_systems.jaimini.karakamsha")),
_sourced("Ghati", f"{DEEP} Ghati Lagna 权力指标", _point(view, [f"{lagna}.Ghati_Lagna.sign"], precision=True, minute_ok=minute_ok)),
_blocked("Vighati", "references/ 未找到 Vighati 条目"),
_blocked("Vighati", f"{ROUTER} §3 必查模块未列 Vighati Lagna;本领域不据此判断"),
_sourced("D1_D9_D10", f"{ROUTER} §2 D10 for career", _leaf(view, "worksheets.advanced_systems.inter_chart_linkage")),
]
if domain == "wealth":
@@ -695,7 +714,7 @@ def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict
f"{ARUDHA}.A10.sign",
])),
_sourced("Hora", f"{DEEP} Hora Lagna 财富指标", _leaf(view, f"{lagna}.Hora_Lagna.sign")),
_blocked("Indu", "references/ 未找到 Indu Lagna 财富条目"),
_blocked("Indu", f"{ROUTER} §5 必查模块与 {WEALTH} §3 未列 Indu Lagna;本领域不据此判断"),
_sourced("Sree", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna", _first(view, [
f"{lagna}.Sree_Lagna.sign",
f"{lagna}.Sri_Lagna.sign",
@@ -725,11 +744,8 @@ def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict
"worksheets.divisional_and_special_charts.sensitive_points.drekkana_22",
"worksheets.divisional_and_special_charts.varga_full.D3_Drekkana",
])),
_blocked("navamsa64", "references/ 未找到第 64 个 Navamsa 条目"),
_sourced("Badhaka", BADHAKA, _first(view, [
"worksheets.advanced_systems.badhaka",
"worksheets.d1_rasi_bhava.badhaka",
])),
_blocked("navamsa64", f"{ROUTER} §7 必查模块未列第 64 个 Navamsa;本领域不据此判断"),
_sourced("Badhaka", BADHAKA, _badhaka(view)),
_blocked("Gulika", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"),
_blocked("Mandi", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"),
_blocked("Pranapada", "references/prashna-complete-guide.md 的 Pranapada 是问事替代计算,未列入本命健康参与层"),
@@ -748,7 +764,7 @@ def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict
if domain == "education":
return [
_varga_item(view, "D24", f"{ROUTER} §2 D24 for education", "D24", minute_ok),
_blocked("D5", "references/ 未找到把 D5 标成学业分盘的段落"),
_blocked("D5", f"{ROUTER} §2 未把 D5 列为学业分盘;本领域不据此判断"),
]
if domain == "migration":
return [
+6 -7
View File
@@ -5,7 +5,6 @@ import argparse
import ast
import hashlib
import json
import os
import re
import subprocess
import tempfile
@@ -18,7 +17,6 @@ ROOT = Path(__file__).resolve().parents[2]
LIBRARY_DIR = ROOT / "references" / "preset_evidence_library"
ENTRIES_DIR = LIBRARY_DIR / "entries"
SCHEMA_PATH = LIBRARY_DIR / "schema.json"
DEFAULT_HER_EXPORT = Path(os.environ.get("PRESET_EVIDENCE_HER_EXPORT", "/workspace/up-run-r40"))
RECEIPT_PATH = "rulings/review_receipt_20261112.json"
RECEIPT_SHA256 = "426c456f99848c14e0ffd20a8657f7251c24cb9ae555d77a64ddeef402ea801d"
VALIDATOR_VERSION = "preset-library-validator.v2"
@@ -422,7 +420,7 @@ def _validate_snapshot(raw: dict[str, bytes], receipt: dict, files: _FileIndex |
return entries, errors
def validate(entries_dir: Path = ENTRIES_DIR, her_export: Optional[Path] = DEFAULT_HER_EXPORT,
def validate(entries_dir: Path = ENTRIES_DIR, her_export: Optional[Path] = None,
our_root: Path = ROOT, *, library_dir: Path = LIBRARY_DIR) -> List[str]:
"""Collect schema and policy errors from every category."""
try:
@@ -617,19 +615,20 @@ def summary(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--her-export", type=Path)
parser.add_argument("--skip-her-export", action="store_true", help="Development checks only")
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--her-export", type=Path, help="Checkout or export of the source revision pinned in VERIFIED.json")
source.add_argument("--skip-her-export", action="store_true", help="Development checks only")
parser.add_argument("--write-verified", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
if args.write_verified and (args.skip_her_export or args.her_export is None):
if args.write_verified and args.skip_her_export:
parser.error("--write-verified requires an explicit --her-export and full validation")
entries = []
errors = []
try:
raw = _library_bytes(ENTRIES_DIR, LIBRARY_DIR)
receipt = _read_receipt(LIBRARY_DIR)
files = None if args.skip_her_export else _FileIndex(args.her_export or DEFAULT_HER_EXPORT)
files = None if args.skip_her_export else _FileIndex(args.her_export)
entries, errors = _validate_snapshot(raw, receipt, files, ROOT)
if files is not None and not errors:
source_revision = _validate_source_pin(files, receipt)
+4 -3
View File
@@ -195,14 +195,14 @@ def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
return SimpleNamespace(**payload)
def _reading_facts(packet: dict, full_reading: dict, minute_confirmed: bool, today: str | None) -> dict[str, Any]:
def _reading_facts(packet: dict, full_reading: dict, minute_confirmed: bool, today: str | None, **mode: Any) -> dict[str, Any]:
"""Facts for the model-written reading (issue #113 R1). Never fails the report."""
try:
try:
from scripts.report_render.reading_facts import build_report_reading_facts
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from report_render.reading_facts import build_report_reading_facts
return build_report_reading_facts(packet, full_reading, minute_confirmed=minute_confirmed, today=today)
return build_report_reading_facts(packet, full_reading, minute_confirmed=minute_confirmed, today=today, **mode)
except Exception as exc: # noqa: BLE001 - the reading is optional, the report is not
return {"schema": "report-reading-facts-v1", "gaps": ["reading_facts_unavailable"], "error_type": type(exc).__name__}
@@ -297,7 +297,7 @@ def _assemble_professional_report_reference(
if include_fact_tables else {}),
**(english if edition == FULL_DATA_EDITION and "en" in languages else {}),
**({"reading_facts": _reading_facts(
packet, full_reading, reading["minute_confirmed"], reading.get("today"),
packet, full_reading, **reading,
)} if reading is not None and edition == FULL_DATA_EDITION else {}),
}
return {
@@ -319,6 +319,7 @@ def _reading_request(body: dict[str, Any]) -> dict[str, Any] | None:
return {
"minute_confirmed": minute_confirmed_from_body(body),
"today": today if isinstance(today, str) else None,
**{key: body[key] for key in ("report_mode", "subject_life_status") if key in body},
}
+52
View File
@@ -0,0 +1,52 @@
"""Badhaka raw facts for D1. PRODUCT computation, pending astrologer review (BUG-1853).
Her engine (yinduzhanxing 8aaf6099) has no natal Badhaka module, so this lives
in a product file, announced in references/product_runtime_contract.md. It only
reads positions the engine already computed and the Badhaka house rule from
``yoga_engine.badhaka_house``; it follows references/badhaka-obstacle-planet-guide.md §二
(identical to her copy) and gives positions, no interpretation.
"""
from __future__ import annotations
from typing import Any
from yoga_engine import badhaka_house
SOURCE = "references/badhaka-obstacle-planet-guide.md §二"
STATUS = "product_computation_pending_astrologer_review"
SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio",
"Sagittarius", "Capricorn", "Aquarius", "Pisces")
LORDS = ("Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Saturn", "Jupiter")
MODALITY = {11: "movable", 9: "fixed", 7: "dual"}
def badhaka_facts(asc_sign: Any, planets: dict[str, dict]) -> dict[str, Any] | None:
"""Badhaka house, sign, lord and the lord's placement, plus occupants.
``planets`` maps a planet name to its D1 record (``sign``, ``house``, optional ``dignity``).
Returns None when the ascendant sign is unknown.
"""
if asc_sign not in SIGNS:
return None
house = badhaka_house(asc_sign)
if house is None:
return None
sign = SIGNS[(SIGNS.index(asc_sign) + house - 1) % 12]
lord = LORDS[SIGNS.index(sign)]
lord_data = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
occupants = sorted(name for name, row in planets.items() if isinstance(row, dict) and row.get("sign") == sign)
return {
"ascendant_sign": asc_sign,
"ascendant_modality": MODALITY[house],
"house": house,
"sign": sign,
"lord": lord,
"lord_sign": lord_data.get("sign"),
"lord_house": lord_data.get("house"),
"lord_dignity": lord_data.get("dignity"),
"occupants": occupants,
"source": SOURCE,
"status": STATUS,
}
@@ -0,0 +1,78 @@
"""Nearest natal varga ascendant sign changes, using the chart's own frame."""
from datetime import datetime
from functools import lru_cache
from ayanamsa_utils import temporary_ayanamsa
from domain_calculation_service import swiss_ephemeris_lock
from jyotish_engine import _birth_hour_decimal, _sidereal_calculation_profile, swe
from varga import calc_varga
_CAP_SECONDS = 240 * 60
_STEP_SECONDS = 30
def _first_change(longitude_at, sign_at, natal_sign, part_width, near, far):
"""Check both halves if a step can cross more than one sign boundary."""
delta = abs((longitude_at(far) - longitude_at(near) + 180) % 360 - 180)
if abs(far - near) > 1 and delta >= part_width:
mid = (near + far) // 2
return _first_change(longitude_at, sign_at, natal_sign, part_width, near, mid) or _first_change(
longitude_at, sign_at, natal_sign, part_width, mid, far,
)
if sign_at(far) == natal_sign:
return None
while abs(far - near) > 1:
mid = (near + far) // 2
if sign_at(mid) == natal_sign:
near = mid
else:
far = mid
return far
def _margin(longitude_at, division, direction):
def sign_at(seconds):
longitude = longitude_at(seconds)
return int(longitude // 30) if division == 1 else calc_varga(longitude, division)["sign_idx"]
natal_sign = sign_at(0)
for seconds in range(_STEP_SECONDS, _CAP_SECONDS + 1, _STEP_SECONDS):
change = _first_change(
longitude_at, sign_at, natal_sign, 30 / division,
direction * (seconds - _STEP_SECONDS), direction * seconds,
)
if change is not None:
return round(abs(change) / 60, 3)
return _CAP_SECONDS / 60
def ascendant_margins(birth: dict, divisions: set[int]) -> dict:
"""Return distances in minutes from the recorded instant, capped at 240."""
try:
natal = datetime.fromisoformat(f"{birth['date']}T{birth['time']}")
tz = float(str(birth["tz"]).removeprefix("UTC"))
latitude, longitude = float(birth["lat"]), float(birth["lon"])
ayanamsa = birth["ayanamsa_name"]
except (KeyError, TypeError, ValueError):
return {}
hour_ut = _birth_hour_decimal(natal.hour, natal.minute, natal.second) - tz
natal_jd = swe.julday(natal.year, natal.month, natal.day, hour_ut)
@lru_cache(maxsize=None)
def longitude_at(seconds):
jd = natal_jd + seconds / 86_400
# Match compute_chart_data's apparent frame and equal-house Ascendant.
_, _, offset = _sidereal_calculation_profile(jd, "legacy")
cusps, _ = swe.houses(jd, latitude, longitude, b"A")
return (cusps[0] - offset) % 360
with swiss_ephemeris_lock(), temporary_ayanamsa(ayanamsa, swe):
return {
f"D{division}": {
"before_minutes": _margin(longitude_at, division, -1),
"after_minutes": _margin(longitude_at, division, 1),
}
for division in sorted(divisions)
}
+241
View File
@@ -0,0 +1,241 @@
"""Per-claim evidence-grade caps for the report reading (`report_evidence_grade_v1`).
Astrologer 2026-11-12 (her repo 8aaf6099, handoff/2026-11-12/preset_q3 §1.3–1.4
and §5.2): the backend fixes each conclusion's required dependencies by route
and claim type, computes a grade ceiling from a six-axis evidence ledger, and the
model may only write at or below it. Grades are not probabilities.
| Grade | Meaning in this contract |
|---|---|
| A | strongly and independently validated on a defined case set (never reached here) |
| B | matching cases plus several independent methods agree (never reached here) |
| C | sourced and computable, but not calibrated on matching cases |
| D | a dependency the conclusion needs is missing, undecidable or in open conflict |
No conclusion in a report has matching external cases (event calibration is
`not_done` everywhere), so the ceiling is C at best. A conclusion falls to D when
a *required* layer of its route is missing, failed, withheld, or sits within the
birth-time tolerance of an ascendant change in a varga it needs. A missing layer
that only the route's checklist names (``checked``) is recorded in the ledger and
must be disclosed; it does not by itself make the conclusion D, because an
unrelated missing technique must not drag valid conclusions down (§5.2).
Dependencies follow her `references/strict-workflow-router.md` §2–§7 mandatory
modules. The split into ``required`` and ``checked`` is the product's v1 reading
of that list and is listed for her review.
"""
from __future__ import annotations
from typing import Any
GRADE_CONTRACT = "report_evidence_grade_v1"
GRADE_ORDER = ("A", "B", "C", "D")
# Product policy, versioned: how far the true birth instant may sit from the
# recorded one. A minute-confirmed record is taken as +/- 1 minute; an
# unconfirmed one has no stated tolerance, so varga stability is not evaluated.
PRECISION_POLICY = "birth_record_minute_v1"
CONFIRMED_TOLERANCE_MINUTES = 1.0
MISSING_STATES = {"blocked", "withheld", "missing", "calculation_failed", "missing_input", "not_computed"}
# Dependency kinds:
# ("p", domain, id) domain participation row
# ("s", id) shared technique row
# ("v", code) varga ascendant stable within the birth-time tolerance
# ("t", key) timing / transit / annual block present
# Each claim type: route (her router section), section ids it may appear in,
# required deps (missing -> D) and checked deps (missing -> disclosed only).
CLAIM_TYPES: dict[str, dict[str, Any]] = {
"character_structure": {
"route": "full-reading-strict §2",
"sections": ("overview", "character"),
"required": [("v", "D1"), ("s", "functional")],
"checked": [("s", "d9"), ("v", "D9"), ("s", "shadbala"), ("s", "avastha"), ("s", "dispositor_chain")],
},
"career_promise": {
"route": "career-timing-strict §3",
"sections": ("overview", "career"),
"required": [("v", "D1"), ("s", "functional"), ("p", "career", "D10"), ("v", "D10")],
"checked": [("s", "d9"), ("p", "career", "AmK"), ("p", "career", "AK"), ("p", "career", "Karakamsha"),
("p", "career", "A10"), ("p", "career", "AL"), ("s", "shadbala"), ("s", "ashtakavarga"),
("s", "dispositor_chain"), ("p", "career", "D1_D9_D10"), ("s", "argala")],
},
"wealth_earning": {
"route": "finance-timing-strict §5",
"sections": ("overview", "wealth"),
"required": [("v", "D1"), ("s", "functional"), ("p", "wealth", "D2"), ("v", "D2")],
"checked": [("p", "wealth", "D11"), ("p", "wealth", "A2"), ("p", "wealth", "A11"), ("p", "wealth", "D10"),
("p", "wealth", "A10"), ("s", "shadbala"), ("s", "ashtakavarga"), ("s", "argala")],
},
"wealth_retention": {
"route": "finance-timing-strict §5",
"sections": ("overview", "wealth"),
"required": [("v", "D1"), ("s", "functional"), ("p", "wealth", "D2"), ("v", "D2")],
"checked": [("p", "wealth", "A2"), ("s", "shadbala"), ("s", "ashtakavarga"), ("s", "argala")],
},
"relationship_structure": {
"route": "relationship-timing-strict §4",
"sections": ("overview", "love"),
"required": [("v", "D1"), ("s", "functional"), ("p", "marriage", "D9_7"), ("v", "D9")],
"checked": [("p", "marriage", "UL"), ("p", "marriage", "A7"), ("p", "marriage", "DK"),
("p", "marriage", "Vivah"), ("s", "shadbala")],
},
"health_pressure": {
"route": "health-timing-strict §7",
"sections": ("overview", "health"),
"required": [("v", "D1"), ("s", "functional"), ("p", "health", "D6"), ("v", "D6")],
"checked": [("p", "health", "D8"), ("v", "D8"), ("p", "health", "D30"), ("v", "D30"),
("p", "health", "drekkana22"), ("s", "shadbala")],
},
"health_obstruction": {
"route": "health-timing-strict §7 + badhaka-obstacle-planet-guide.md §二",
"sections": ("health",),
"required": [("v", "D1"), ("p", "health", "Badhaka")],
"checked": [],
},
"timing_window": {
"route": "event-timing-strict §6",
"sections": ("overview", "career", "wealth", "love", "health", "now"),
"required": [("s", "vimshottari"), ("s", "narayana"), ("t", "transits")],
"checked": [("t", "annual"), ("t", "double_transit"), ("s", "yogini"), ("s", "kp")],
},
}
GRADED_SECTIONS = ("character", "career", "wealth", "love", "health", "now")
def _dep_id(dep: tuple) -> str:
return ":".join(str(part) for part in dep)
def _participation(facts: dict, domain: str, item_id: str) -> str:
rows = ((facts.get("domains") or {}).get(domain) or {}).get("participation") or []
for row in rows:
if isinstance(row, dict) and row.get("id") == item_id:
return str(row.get("state") or "missing")
return "missing"
def _shared(facts: dict, item_id: str) -> str:
for row in facts.get("shared_techniques") or []:
if isinstance(row, dict) and row.get("id") == item_id:
return str(row.get("state") or "missing")
return "missing"
def _varga_stability(facts: dict, code: str) -> tuple[str, dict[str, Any]]:
precision = facts.get("birth_precision") or {}
margin = (precision.get("ascendant_margins") or {}).get(code)
if not facts.get("minute_confirmed"):
return "not_evaluated", {"policy": PRECISION_POLICY, "reason": "birth time not confirmed to the minute"}
if not isinstance(margin, dict):
return "missing", {"policy": PRECISION_POLICY, "reason": "no ascendant margin for this varga"}
nearest = min(float(margin.get("before_minutes", 0)), float(margin.get("after_minutes", 0)))
detail = {"policy": PRECISION_POLICY, "nearest_change_minutes": nearest,
"tolerance_minutes": CONFIRMED_TOLERANCE_MINUTES}
return ("stable" if nearest > CONFIRMED_TOLERANCE_MINUTES else "boundary_sensitive"), detail
def _block_state(facts: dict, key: str) -> str:
if key == "transits":
return "executed" if (facts.get("transits") or {}).get("planets") else "missing"
if key == "annual":
annual = facts.get("annual") or {}
return "missing" if annual.get("status") == "blocked" or not annual else "executed"
if key == "double_transit":
double = (facts.get("transits") or {}).get("double_transit") or {}
return "executed" if double.get("checked_houses") else "missing"
return "missing"
def _parameter_sensitive(facts: dict) -> set[str]:
listed = (facts.get("blocked_states") or {}).get("parameter_sensitive") or []
out = {str(item) for item in listed}
narayana_status = ((facts.get("timing") or {}).get("narayana") or {}).get("status")
if narayana_status in {"partial", "partial_verified", "parameter_sensitive", "conditional"}:
out.add("narayana")
return out
def _evaluate(facts: dict, dep: tuple, sensitive: set[str]) -> dict[str, Any]:
kind = dep[0]
detail: dict[str, Any] = {}
if kind == "p":
state = _participation(facts, dep[1], dep[2])
elif kind == "s":
state = _shared(facts, dep[1])
if dep[1] in sensitive and state == "executed":
state = "parameter_sensitive"
elif kind == "v":
state, detail = _varga_stability(facts, dep[1])
else:
state = _block_state(facts, dep[1])
return {"id": _dep_id(dep), "state": state, **({"detail": detail} if detail else {})}
def _is_missing(row: dict) -> bool:
return row["state"] in MISSING_STATES or row["state"] == "boundary_sensitive"
def _contradictions(facts: dict) -> list[dict[str, Any]]:
functional = facts.get("functional") or {}
return [row for row in functional.get("natural_vs_functional_conflicts") or [] if isinstance(row, dict)]
def claim_grade(facts: dict, claim_type: str) -> dict[str, Any]:
spec = CLAIM_TYPES[claim_type]
sensitive = _parameter_sensitive(facts)
required = [_evaluate(facts, dep, sensitive) for dep in spec["required"]]
checked = [_evaluate(facts, dep, sensitive) for dep in spec["checked"]]
missing_required = [row["id"] for row in required if _is_missing(row)]
missing_checked = [row["id"] for row in checked if _is_missing(row)]
sensitive_rows = [row["id"] for row in required + checked if row["state"] == "parameter_sensitive"]
stability = [row for row in required + checked if row["id"].startswith("v:")]
if any(row["state"] == "boundary_sensitive" for row in stability):
purpose = "boundary_sensitive"
elif any(row["state"] == "not_evaluated" for row in stability):
purpose = "not_evaluated"
else:
purpose = "stable"
cap = "D" if missing_required else "C"
reasons = (
[f"required dependency unavailable: {item}" for item in missing_required]
or ["no matching external cases: event calibration not done"]
)
return {
"route": spec["route"],
"sections": list(spec["sections"]),
"cap": cap,
"reasons": reasons,
"required": required,
"checked": checked,
"must_disclose": missing_checked,
"parameter_sensitive": sensitive_rows,
# Six axes (§5.2), raw enums; a missing value never defaults to good.
"ledger": {
"case_fit": "none",
"method_maturity": "source_bounded" if not missing_required else "dependency_missing",
"independent_agreement": "not_assessed",
"purpose_stability": purpose,
"external_calculation_check": "not_done",
"contradiction": [f"{row.get('planet')}: natural {row.get('natural')} / functional {row.get('functional')}"
for row in _contradictions(facts)],
},
}
def evidence_grades(facts: dict) -> dict[str, Any]:
"""The per-claim ceilings the reading must respect."""
return {
"contract": GRADE_CONTRACT,
"precision_policy": PRECISION_POLICY,
"grade_order": list(GRADE_ORDER),
"graded_sections": list(GRADED_SECTIONS),
"claims": {name: claim_grade(facts, name) for name in CLAIM_TYPES},
}
def grade_allowed(grade: str, cap: str) -> bool:
"""A grade may equal its ceiling or be weaker (later in GRADE_ORDER)."""
return GRADE_ORDER.index(grade) >= GRADE_ORDER.index(cap)
+162 -38
View File
@@ -10,9 +10,8 @@ birth parameters, all houses, every domain's vargas, lords, functional roles,
yogas with their conditions, all current dasha layers (Vimshottari and
Narayana with dates, plus every other family's current period and status),
transits, strengths, Ashtakavarga, KP, Jaimini, the annual chart, evidence
paths, method differences and blocked states. Nothing here is a new
algorithm: values are packet leaves, a date is the birth date plus the age the
packet gives, and a conflict is a comparison of two packet values.
paths, method differences and blocked states. The birth-precision margins
reuse the natal engine's ascendant calculation to bracket sign changes.
"""
from __future__ import annotations
@@ -22,6 +21,10 @@ import re
from datetime import date
from typing import Any
from .birth_precision_margins import ascendant_margins
from .evidence_grade import evidence_grades
from .parity_chapters import _VIMSOPAKA_DIGNITY
try:
from scripts.consult_card_domain_facts import (
PLANETS,
@@ -63,10 +66,7 @@ except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from shadbala_display import row_fields
from pl9_full_data_export import full_data_domain_base, read_full_data_domain
# 原值:report-reading-facts-v1(只有 varga / houses / planets_in_varga / significators 与 Vimshottari)
# 新值:v2,按占星师方案 item 10 的完整契约
# 原因:产品 2026-11-07 占星师方案:报告完整运行 skill、导出保留审计
READING_FACTS_SCHEMA = "report-reading-facts-v2"
READING_FACTS_SCHEMA = "report-reading-facts-v3"
READING_DOMAINS = ("general", "career", "wealth", "marriage", "health")
TIMING = "worksheets.timing_and_predictive_systems"
DASHA = f"{TIMING}.dasha.current_dasha"
@@ -84,7 +84,8 @@ KP = "worksheets.advanced_systems.kp"
JAIMINI = "worksheets.advanced_systems.jaimini"
YOGA = "worksheets.advanced_systems.yoga"
SADE_SATI = "worksheets.advanced_systems.yogas_doshas.sade_sati"
LAGNAS = "worksheets.divisional_and_special_charts.special_lagnas"
DIVISIONAL = "worksheets.divisional_and_special_charts"
LAGNAS = f"{DIVISIONAL}.special_lagnas"
LEDGER = "full_report_pack.sections.d1_d60_ledger"
UPCOMING_SUB_PERIODS = 2
UPCOMING_NARAYANA = 2
@@ -95,6 +96,35 @@ NOTE_LIMIT = 280
# Statuses that keep a technique out of the judgment (interpretation_status_contract.md).
BLOCKED_STATES = {"blocked", "gap", "error", "missing"}
CONDITIONAL_STATES = {"parameter_sensitive", "partial", "partial_verified", "conflict", "conditional"}
UNUSED_REASONS = {"not_routed", "not_in_domain_route"}
PARTICIPATION_REASON_CODES = {
("career", "Vighati"): "not_in_domain_route",
("wealth", "Indu"): "not_in_domain_route",
("wealth", "AL2"): "not_in_domain_route",
("wealth", "AL11"): "not_in_domain_route",
("marriage", "UL2"): "not_in_domain_route",
("health", "navamsa64"): "not_in_domain_route",
("health", "Gulika"): "not_in_domain_route",
("health", "Mandi"): "not_in_domain_route",
("health", "Pranapada"): "not_in_domain_route",
("education", "D5"): "not_in_domain_route",
("migration", "D12"): "not_routed",
("family", "D4"): "not_routed",
("family", "D7"): "not_routed",
("general", "D10"): "not_routed",
("general", "D2"): "not_routed",
}
STRUCTURED_VALUES = {
"Badhaka": ("ascendant_modality", "house", "sign", "lord", "lord_sign", "lord_house", "lord_dignity", "occupants", "status"),
}
UNUSED_POINT_PATHS = {
"Indu": f"{LAGNAS}.Indu_Lagna.sign",
"Vighati": f"{LAGNAS}.ViGhati_Lagna.sign",
"Pranapada": f"{LAGNAS}.Pranapada_Lagna.sign",
"Gulika": f"{DIVISIONAL}.upagrahas.raw.Gulika.sign_idx",
"Mandi": f"{DIVISIONAL}.upagrahas.raw.Maandi.sign_idx",
"navamsa64": f"{DIVISIONAL}.sensitive_points.navamsa_64th.sign",
}
_ISSUE_REFERENCE = r"#\d+(?:[ \t]+Q\d+\b(?:[ \t]*/[ \t]*Q\d+\b)*)?"
_INTERNAL_ISSUE_REFERENCE = re.compile(
@@ -364,26 +394,53 @@ def _houses(view: dict, packet: dict) -> dict[str, Any]:
return out
def _participation(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]:
def _unused_value(view: dict, item_id: str, minute_confirmed: bool) -> str | None:
if item_id in ("Vighati", "Pranapada") and not minute_confirmed:
return None
if path := UNUSED_POINT_PATHS.get(item_id):
value = dig(view, path)
if type(value) is int and 0 <= value < len(SIGN_ORDER):
return SIGN_ORDER[value]
return value if isinstance(value, str) and value else None
chart, _ = _varga_chart(view, item_id)
if isinstance(chart, dict):
return dig(chart, "Ascendant.sign") or dig(chart, "ascendant.sign")
return None
def _participation(rows: list[dict[str, Any]], domain: str, view: dict, minute_confirmed: bool) -> list[dict[str, Any]]:
"""Each technique's state for the domain; small values ride along."""
out, not_executed = [], []
out = []
for item in rows:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
continue
status = item.get("status")
state = {"value": "executed", "withheld": "withheld"}.get(str(status), "blocked")
row: dict[str, Any] = {"id": item["id"], "state": state}
item_id = item["id"]
reason = None
if status == "blocked":
reason = PARTICIPATION_REASON_CODES[domain, item_id]
elif status != "value" and status != "withheld":
reason = "not_computed" # A packet leaf is absent. The card does not report an engine exception.
state = {"value": "executed", "withheld": "withheld"}.get(status, "blocked")
if reason in UNUSED_REASONS:
state = reason
row: dict[str, Any] = {"id": item_id, "state": state}
if reason is not None:
row["reason_code"] = reason
value = item.get("value")
if state == "executed" and isinstance(value, (str, int, float, bool)):
row["value"] = value
elif state == "executed" and item_id in STRUCTURED_VALUES and isinstance(value, dict):
# BUG-1853: Badhaka is a small record; the reading needs its positions, not just "executed".
row["value"] = {key: value[key] for key in STRUCTURED_VALUES[item_id] if value.get(key) not in (None, "", [])}
if state in UNUSED_REASONS and (value := _unused_value(view, item_id, minute_confirmed)) is not None:
row.update(computed=True, value=value)
if state == "withheld":
row["reason"] = PRECISION_SENTENCE
if isinstance(item.get("citation"), str):
row["source"] = item["citation"]
if state != "executed":
not_executed.append(item["id"])
out.append(row)
return out, not_executed
return out
def _domain_factors(row: dict) -> list[dict[str, Any]]:
@@ -398,6 +455,16 @@ SIGN_ORDER = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "S
"Sagittarius", "Capricorn", "Aquarius", "Pisces")
def _dignity_zh(value: str) -> str | None:
chinese, opening, english = value.partition("(")
label = english.removesuffix(")") if opening else value
key = {"Friendly Sign": "Friend", "Enemy Sign": "Enemy"}.get(label, label)
entry = _VIMSOPAKA_DIGNITY.get(key)
if entry:
return entry[0]
return chinese if opening else None
def _varga_signs(view: dict, code: str, minute_confirmed: bool) -> Any:
"""The varga as the packet holds it: ascendant sign, each planet's sign and dignity.
@@ -428,11 +495,12 @@ def _varga_signs(view: dict, code: str, minute_confirmed: bool) -> Any:
dignity = dignities.get(planet) or node.get("dignity")
if isinstance(dignity, str) and dignity:
row["dignity"] = dignity
row["dignity_zh"] = _dignity_zh(dignity)
out[planet] = row
return out
def _domain(row: dict, view: dict, shared: set[str]) -> dict[str, Any]:
def _domain(row: dict, view: dict, shared: set[str], checked_houses: list[int]) -> dict[str, Any]:
primary = row["domain_varga"]
vargas = list(row.get("domain_vargas") or [primary])
varga_houses: dict[str, dict[str, Any]] = {}
@@ -446,10 +514,16 @@ def _domain(row: dict, view: dict, shared: set[str]) -> dict[str, Any]:
houses[planet] = house
if houses:
varga_houses[code] = houses
participation, not_executed = _participation(
[item for item in row.get("participation") or [] if item.get("id") not in shared]
)
items = [item for item in row["participation"] if item["id"] not in shared]
if not any(str(house) in row["houses"] for house in checked_houses):
items = [item for item in items if item["id"] != "double_transit"]
participation = _participation(items, row["domain"], view, row["minute_confirmed"])
states = {item["id"]: item["state"] for item in participation}
charts = {code: chart for code in vargas if (chart := _varga_signs(view, code, row["minute_confirmed"])) is not None}
for code, chart in charts.items():
if isinstance(chart, dict):
item_id = "D9_7" if row["domain"] == "marriage" and code == "D9" else code
chart["routed_for_domain"] = states.get(item_id) == "executed"
return {
"varga": primary,
"vargas": vargas,
@@ -458,7 +532,7 @@ def _domain(row: dict, view: dict, shared: set[str]) -> dict[str, Any]:
"varga_charts": charts,
"significators": _domain_factors(row),
"participation": participation,
"not_executed": not_executed,
"not_executed": [item["id"] for item in participation if item["state"] != "executed"],
}
@@ -549,8 +623,11 @@ def _transits(packet: dict) -> dict[str, Any]:
out["sade_sati"] = _pick(sade, ("is_active", "phase", "moon_sign", "phases"))
double = dig(packet, DOUBLE_TRANSIT)
if isinstance(double, dict):
event_house = double.get("event_house")
out["double_transit"] = {
**_pick(double, ("status", "summary", "event_house", "transit_date", "claim_boundary")),
"checked_houses": [event_house] if type(event_house) is int and 1 <= event_house <= 12 else [],
"scope": "Only the listed houses were checked. This does not indicate activation of other houses or domains.",
"hits": [
_pick(row, ("layer", "target", "strength"))
for row in (double.get("double_transit") or []) if isinstance(row, dict)
@@ -561,9 +638,7 @@ def _transits(packet: dict) -> dict[str, Any]:
def _annual(packet: dict) -> dict[str, Any]:
pack = dig(packet, ANNUAL)
if not isinstance(pack, dict):
return {"status": "blocked"}
out: dict[str, Any] = {}
out: dict[str, Any] = {} if isinstance(pack, dict) else {"status": "blocked"}
target = dig(pack, "profile.target_year")
if target is not None:
out["target_year"] = target
@@ -577,11 +652,21 @@ def _annual(packet: dict) -> dict[str, Any]:
**_pick(pack.get("year_lord"), ("status",)),
}
periods = dig(pack, "mudda_dasha.periods")
if isinstance(periods, list):
out["mudda_dasha"] = [
mudda = dig(pack, "mudda_dasha.data")
mudda = mudda if isinstance(mudda, dict) else {}
moon_source = mudda.get("moon_source")
if moon_source is None:
moon_source = {
"moon_at_birth_time": "natal", "moon_in_varshaphala": "varshaphala",
}.get(mudda.get("balance_profile"))
out["mudda_dasha"] = {
"moon_source": moon_source,
"balance_method": mudda.get("balance_method"),
"periods": [
{**_pick(row, ("lord",)), **{key: _day(row.get(key)) for key in ("start", "end") if _day(row.get(key))}}
for row in periods if isinstance(row, dict)
]
for row in (periods if isinstance(periods, list) else []) if isinstance(row, dict)
],
}
return out
@@ -661,13 +746,14 @@ def _method_differences(packet: dict, narayana: dict, functional: dict, transits
def _blocked_states(
domains: dict, shared: list[dict], families: list[dict], evidence: dict, transits: dict, annual: dict,
) -> dict[str, Any]:
labels: dict[str, list[str]] = {"blocked": [], "parameter_sensitive": [], "not_applicable": [], "withheld": []}
labels: dict[str, list[str]] = {
"blocked": [], "not_routed": [], "not_in_domain_route": [],
"parameter_sensitive": [], "not_applicable": [], "withheld": [],
}
for name, row in domains.items():
for item in [*(row.get("participation") or []), *(shared if name == "general" else [])]:
if item["state"] == "blocked":
labels["blocked"].append(f"{name}:{item['id']}")
elif item["state"] == "withheld":
labels["withheld"].append(f"{name}:{item['id']}")
if item["state"] in labels:
labels[item["state"]].append(f"{name}:{item['id']}")
for family in families:
status = str(family.get("status") or "")
if status in BLOCKED_STATES:
@@ -684,10 +770,12 @@ def _blocked_states(
labels["parameter_sensitive"].append(f"audit:{row['technique']}")
if not transits.get("planets"):
labels["blocked"].append("transits")
if not dig(transits, "double_transit.checked_houses"):
labels["blocked"].append("general:double_transit")
if annual.get("status") == "blocked":
labels["blocked"].append("annual")
labels["parameter_sensitive"].append("kp")
return {key: value for key, value in labels.items() if value}
return {key: value for key, value in labels.items() if value or key in UNUSED_REASONS}
def _birth_parameters(packet: dict, minute_confirmed: bool) -> dict[str, Any]:
@@ -701,14 +789,38 @@ def _birth_parameters(packet: dict, minute_confirmed: bool) -> dict[str, Any]:
}
def _birth_precision(full_reading: dict, view: dict, domains: dict, minute_confirmed: bool) -> dict[str, Any]:
divisions = {1}
for domain in domains.values():
divisions.update(int(code[1:]) for code, chart in domain["varga_charts"].items() if isinstance(chart, dict))
# D60 is referenced by the facts' D1–D60 ledger. Keep the existing precision gate.
if minute_confirmed and _varga_chart(view, "D60")[1]:
divisions.add(60)
birth = dig(full_reading, "modules.chart.birth_info") or {}
return {
"minute_confirmed": minute_confirmed,
"purpose_stability": "not_evaluated",
"basis": "The purpose-specific interval contract (2026-11-09 interval_rectification handoff) is not integrated.",
"ascendant_margins": ascendant_margins(birth, divisions),
}
def build_report_reading_facts(
packet: dict,
full_reading: dict,
*,
minute_confirmed: bool,
today: str | None = None,
report_mode: str = "living_consultation",
subject_life_status: str = "unknown",
) -> dict[str, Any]:
"""Packet-sourced facts for the reading. Raises on any non-packet consult leaf."""
if report_mode not in ("living_consultation", "historical_replay"):
raise ValueError("unsupported report_mode")
if subject_life_status not in ("living", "deceased", "unknown"):
raise ValueError("unsupported subject_life_status")
if subject_life_status == "deceased" and report_mode == "living_consultation":
raise ValueError("a deceased subject requires historical_replay")
working = _attach_graha_drishti(copy.deepcopy(packet), full_reading)
# One detached copy of the packet serves every domain's facts and their leaf check (issue #122).
base = full_data_domain_base(working)
@@ -722,17 +834,23 @@ def build_report_reading_facts(
kept = _compact({key: value for key, value in record.items() if key not in ("domain_varga_house", "domain_varga_houses")})
if kept.get("present"):
kept.pop("present")
for key in ("d1_dignity", "d9_dignity"):
if isinstance(kept.get(key), str):
kept[f"{key}_zh"] = _dignity_zh(kept[key])
planets[name] = {**kept, **_planet_extras(working, name)}
ids = [{item.get("id") for item in row.get("participation") or []} for row in rows.values()]
shared = set.intersection(*ids) if ids else set()
shared_rows, _ = _participation([item for item in general.get("participation") or [] if item.get("id") in shared])
shared = set.intersection(*ids) - {"double_transit"}
view = read_full_data_domain(working, "general", base=base)
domains = {name: _domain(row, view, shared) for name, row in rows.items()}
shared_rows = _participation(
[item for item in general["participation"] if item["id"] in shared], "general", view, minute_confirmed,
)
transits = _transits(working)
checked_houses = dig(transits, "double_transit.checked_houses") or []
domains = {name: _domain(row, view, shared, checked_houses) for name, row in rows.items()}
ascendant = dig(packet, "core_chart.ascendant")
reference_day = str(today or dig(packet, f"{TRANSIT}.target_date") or date.today().isoformat())[:10]
narayana = _narayana(working)
functional = _functional(working)
transits = _transits(working)
families = [
row for row in _dasha_families(working, reference_day)
# The dated Narayana is in `timing`; the master pack's copy is not run.
@@ -745,8 +863,12 @@ def build_report_reading_facts(
facts = {
"schema": READING_FACTS_SCHEMA,
"minute_confirmed": minute_confirmed,
**({"today": today} if today else {}),
"report_mode": report_mode,
"subject_life_status": subject_life_status,
"current_advice_allowed": report_mode == "living_consultation",
"observation_date": today or reference_day,
"birth_parameters": _birth_parameters(working, minute_confirmed),
"birth_precision": _birth_precision(full_reading, view, domains, minute_confirmed),
"ascendant": _compact({
key: {"status": "value", "check": "path", "value": ascendant.get(key)}
for key in ("sign", "lord", "nakshatra", "degree_in_sign")
@@ -783,4 +905,6 @@ def build_report_reading_facts(
"method_differences": _method_differences(working, narayana, functional, transits),
"blocked_states": _blocked_states(domains, shared_rows, families, evidence, transits, annual),
}
# Astrologer 2026-11-12 Q3-2: the backend fixes each claim type's grade ceiling.
facts["evidence_grades"] = evidence_grades(facts)
return _strip_internal_issue_references(facts)
+20
View File
@@ -0,0 +1,20 @@
"""BUG-1853: Badhaka raw facts (product computation) follow the source table per ascendant modality."""
from scripts.report_render.badhaka_facts import STATUS, badhaka_facts
def test_source_table_for_cancer_leo_gemini():
# references/badhaka-obstacle-planet-guide.md §二: Cancer → Taurus/Venus, Leo → Aries/Mars, Gemini → Sagittarius/Jupiter.
for asc, house, sign, lord in (("Cancer", 11, "Taurus", "Venus"), ("Leo", 9, "Aries", "Mars"),
("Gemini", 7, "Sagittarius", "Jupiter")):
planets = {lord: {"sign": "Libra", "house": 2, "dignity": "Neutral"}, "Ketu": {"sign": sign, "house": house}}
record = badhaka_facts(asc, planets)
assert (record["house"], record["sign"], record["lord"]) == (house, sign, lord)
assert (record["lord_sign"], record["lord_house"]) == ("Libra", 2)
assert record["occupants"] == ["Ketu"]
assert record["status"] == STATUS
def test_unknown_ascendant_gives_nothing():
assert badhaka_facts("Unknown", {}) is None
assert badhaka_facts(None, {}) is None
+36 -10
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import copy
import json
import os
import subprocess
from pathlib import Path
import pytest
@@ -11,9 +13,34 @@ from scripts.preset_evidence import library as lib
ROOT = Path(__file__).resolve().parents[1]
HER = lib.DEFAULT_HER_EXPORT
needs_export = pytest.mark.skipif(not (HER / "references" / "yoga_rules.json").is_file(),
reason=f"her export not present at {HER}")
@pytest.fixture(scope="module")
def her_export() -> Path:
value = os.environ.get("PRESET_EVIDENCE_HER_EXPORT")
if not value:
pytest.skip("Set PRESET_EVIDENCE_HER_EXPORT to check the pinned source export.")
verified = json.loads((lib.LIBRARY_DIR / "VERIFIED.json").read_bytes())
mismatch = "来源与 VERIFIED.json 固定的版本不一致"
try:
directory = Path(value).resolve()
if (directory / ".git").exists():
git = ["git", "-c", f"safe.directory={directory.as_posix()}", "-C", str(directory)]
subprocess.check_output([*git, "status", "-sb"], stderr=subprocess.PIPE, timeout=20)
root, revision = subprocess.check_output(
[*git, "rev-parse", "--show-toplevel", "HEAD"], stderr=subprocess.PIPE, timeout=20,
).decode("utf-8").splitlines()
matches = Path(root).resolve() == directory and revision == verified["source_repo_commit"]
else:
files = lib._FileIndex(directory)
for relative in verified["source_files"]:
files.read(relative)
matches = files.hashes() == verified["source_files"]
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
matches = False
if not matches:
pytest.fail(mismatch, pytrace=False)
return directory
def _entry(entry_id: str) -> dict:
@@ -40,9 +67,8 @@ def test_policy_holds_without_export():
assert lib.validate(her_export=None) == []
@needs_export
def test_every_her_file_exists_sha_matches_and_quotes_are_verbatim():
assert lib.validate(her_export=HER) == []
def test_every_her_file_exists_sha_matches_and_quotes_are_verbatim(her_export):
assert lib.validate(her_export=her_export) == []
def test_ids_unique_and_seed_counts():
@@ -98,14 +124,14 @@ def test_missing_field_needs_a_note_and_missing_source_cannot_be_admitted():
assert any("requires a located source" in e for e in lib.entry_policy_errors(entry))
@needs_export
def test_changed_her_file_is_caught(tmp_path):
def test_changed_her_file_is_caught(her_export):
files = lib._FileIndex(her_export)
entry = _entry("functional.pvr_table30")
entry["source"]["sha256"] = "0" * 64
assert any("sha256 mismatch" in e for e in lib.entry_policy_errors(entry, lib._FileIndex(HER)))
assert any("sha256 mismatch" in e for e in lib.entry_policy_errors(entry, files))
entry = _entry("functional.pvr_table30")
entry["source"]["quote_excerpt"] = "|Vi|—|Mercury,Venus,Saturn|"
assert any("quote not found" in e for e in lib.entry_policy_errors(entry, lib._FileIndex(HER)))
assert any("quote not found" in e for e in lib.entry_policy_errors(entry, files))
def test_open_shadbala_parameters_and_harsha_stay_blocked():
@@ -226,6 +226,14 @@ def test_fingerprint_includes_facts_domain_and_branch():
assert len({row["match_fingerprint"] for row in (first, other_domain, other_branch, other_facts)}) == 4
def test_cli_requires_an_explicit_source_option(capsys):
with pytest.raises(SystemExit) as error:
lib.main([])
assert error.value.code == 2
diagnostic = capsys.readouterr().err
assert "--her-export" in diagnostic and "--skip-her-export" in diagnostic
def test_skipping_export_cannot_write_a_manifest():
manifest = lib.LIBRARY_DIR / "VERIFIED.json"
before = manifest.read_bytes()
+175
View File
@@ -0,0 +1,175 @@
"""BUG-1848: report facts distinguish calculation, domain routing and evidence scope."""
import json
import re
from copy import deepcopy
from datetime import datetime, timedelta
import pytest
from scripts.consult_card_domain_facts import CONSULTATION_DOMAINS, _domain_participation, _shared_participation, dig
from scripts.chart_choice.boundaries import ChartRequest, ascendant_longitude, varga_sign
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.pl9_full_data_export import REPORT_VERSION_FULL
from scripts.professional_report_reference import _export_args, _reading_facts, _reading_request, _with_report_tajika_view
from scripts.report_render import reading_facts as rf
from tests.public_chart_reports import PUBLIC_REPORT_CASES
from tests.test_consult_card_full_source import PUBLIC, _public_body
OBSERVATION_DATE = "2026-11-11"
FIXTURE_SUBJECT = "Fictional report identity sentinel"
FIXTURE_BIRTHPLACE = "Fictional report birthplace sentinel"
@pytest.fixture(scope="module", params=PUBLIC_REPORT_CASES)
def public_report(request):
case_id = request.param
body = {**_public_body(case_id), "today": OBSERVATION_DATE, "target_year": 2026,
"include_reading_facts": True, "birth_time_accuracy": "confirmed"}
args = _export_args(body)
reading = cmd_full_reading(args)
packet = _with_report_tajika_view(dict(build_professional_report_reference_packet(
reading, args, [], include_annual_method_reference=True,
)))
packet["report_version"] = REPORT_VERSION_FULL
packet["birth_info"].update(name=FIXTURE_SUBJECT, birthplace=FIXTURE_BIRTHPLACE)
return case_id, body, reading, packet
def test_static_blocked_citations_have_reason_codes():
citations = {
(domain, row["id"]): row["citation"]
for domain in CONSULTATION_DOMAINS
for row in _domain_participation({}, domain, True)
if row["status"] == "blocked"
}
citations.update({
("general", row["id"]): row["citation"]
for row in _shared_participation({}, True) if row["status"] == "blocked"
})
assert citations.keys() == rf.PARTICIPATION_REASON_CODES.keys()
assert set(rf.PARTICIPATION_REASON_CODES.values()) <= {
"not_routed", "not_in_domain_route", "missing_input", "not_computed", "calculation_failed",
}
def test_public_report_v3_facts(public_report):
case_id, body, reading, packet = public_report
options = _reading_request(body)
facts = _reading_facts(packet, reading, **options)
assert facts["schema"] == "report-reading-facts-v3"
assert facts["observation_date"] == OBSERVATION_DATE
assert "today" not in facts
assert facts["report_mode"] == "living_consultation"
assert facts["subject_life_status"] == "unknown"
assert facts["current_advice_allowed"] is True
blocked = facts["blocked_states"]
assert "general:D10" in blocked["not_routed"]
health_points = {f"health:{point}" for point in ("Gulika", "Mandi", "Pranapada")}
assert health_points <= set(blocked["not_in_domain_route"])
assert not (health_points | {"general:D10"}) & set(blocked["blocked"])
wealth = facts["domains"]["wealth"]
indu = next(row for row in wealth["participation"] if row["id"] == "Indu")
assert indu["reason_code"] == indu["state"] == "not_in_domain_route"
assert indu["computed"] is True
assert indu["value"] == dig(packet, f"{rf.LAGNAS}.Indu_Lagna.sign")
# BUG-1852: the citation names the route that leaves Indu out, not a failed search of references/.
assert "未找到" not in indu["source"] and "Indu Lagna" in indu["source"]
# BUG-1853: Badhaka is computed from the ascendant and reaches the health facts with its positions.
badhaka = next(row for row in facts["domains"]["health"]["participation"] if row["id"] == "Badhaka")
assert badhaka["state"] == "executed"
assert badhaka["value"]["house"] in (7, 9, 11)
assert badhaka["value"]["sign"] and badhaka["value"]["lord"]
# Product computation, not her engine (owner rule 2026-11-07): the record says so.
assert badhaka["value"]["status"] == "product_computation_pending_astrologer_review"
assert dig(packet, "worksheets.advanced_systems.badhaka") is None
assert facts["evidence_grades"]["claims"]["health_obstruction"]["cap"] != "D"
general = facts["domains"]["general"]
assert general["varga_charts"]["D10"]["routed_for_domain"] is False
assert facts["domains"]["career"]["varga_charts"]["D10"]["routed_for_domain"] is True
for name, domain in facts["domains"].items():
for code, chart in domain["varga_charts"].items():
if chart["routed_for_domain"]:
assert f"{name}:{code}" not in blocked["blocked"] + blocked["not_routed"]
for placement in chart.values():
if isinstance(placement, dict) and "dignity" in placement:
assert placement["dignity_zh"]
jupiter = wealth["varga_charts"]["D2"]["Jupiter"]
assert jupiter["dignity"] == "Exalted"
assert jupiter["dignity_zh"] == "入旺"
double_transit = facts["transits"]["double_transit"]
assert double_transit["event_house"] == 7
assert double_transit["checked_houses"] == [7]
assert "other houses or domains" in double_transit["scope"]
assert all(row["id"] != "double_transit" for row in facts["shared_techniques"])
assert all(row["id"] != "double_transit" for row in facts["domains"]["health"]["participation"])
assert any(row["id"] == "double_transit" for row in facts["domains"]["marriage"]["participation"])
precision = facts["birth_precision"]
assert precision["minute_confirmed"] is True
assert precision["purpose_stability"] == "not_evaluated"
assert "interval_rectification" in precision["basis"]
margins = precision["ascendant_margins"]
assert set(margins) >= {"D1", "D2", "D6", "D8", "D9", "D10", "D11", "D30", "D60"}
for direction in ("before_minutes", "after_minutes"):
assert 0 < margins["D60"][direction] <= margins["D1"][direction] <= 240
assert rf.build_report_reading_facts(packet, reading, **options)["birth_precision"] == precision
natal = datetime(*(body[key] for key in ("year", "month", "day", "hour", "minute", "second")))
request = ChartRequest(natal.date().isoformat(), body["lat"], body["lon"], body["tz"], body["ayanamsa"])
for division in (1, 60):
initial_sign = varga_sign(ascendant_longitude(request, natal), division)
for field, direction in (("before_minutes", -1), ("after_minutes", 1)):
offset = round(margins[f"D{division}"][field] * 60)
inside = natal + timedelta(seconds=direction * (offset - 1))
outside = natal + timedelta(seconds=direction * offset)
assert varga_sign(ascendant_longitude(request, inside), division) == initial_sign
assert varga_sign(ascendant_longitude(request, outside), division) != initial_sign
mudda = facts["annual"]["mudda_dasha"]
assert dig(packet, f"{rf.ANNUAL}.mudda_dasha.data.balance_profile") == "moon_at_birth_time"
assert mudda["moon_source"] == "natal"
assert mudda["balance_method"] == dig(packet, f"{rf.ANNUAL}.mudda_dasha.data.balance_method")
assert mudda["balance_method"] and mudda["periods"]
encoded = json.dumps(facts, ensure_ascii=False)
public_source = json.loads(PUBLIC.read_text(encoding="utf-8"))
public_case = next(row for row in public_source["cases"] if row["id"] == case_id)
for identity in (public_case["name"], FIXTURE_SUBJECT, FIXTURE_BIRTHPLACE):
assert identity not in encoded
for key in ("lat", "lon"):
assert str(body[key]) not in encoded
assert re.search(r'"(?:name_of_subject|birth_place|birthplace|place|latitude|longitude|lat|lon)"\s*:', encoded) is None
assert re.search(r"#\d", encoded) is None
def test_historical_replay_reaches_product_facts_and_preserves_precision_gate(public_report):
_, body, reading, packet = public_report
options = _reading_request({**body, "report_mode": "historical_replay", "subject_life_status": "deceased",
"birth_time_accuracy": "minute"})
facts = _reading_facts(packet, reading, **options)
assert facts["report_mode"] == "historical_replay"
assert facts["subject_life_status"] == "deceased"
assert facts["current_advice_allowed"] is False
assert facts["birth_precision"]["minute_confirmed"] is False
assert "D30" not in facts["birth_precision"]["ascendant_margins"]
assert "D60" not in facts["birth_precision"]["ascendant_margins"]
assert facts["domains"]["health"]["varga_charts"]["D30"] == rf.PRECISION_SENTENCE
with pytest.raises(ValueError, match="deceased subject requires historical_replay"):
rf.build_report_reading_facts(packet, reading, **{**options, "report_mode": "living_consultation"})
def test_missing_mudda_axes_are_null(public_report):
_, _, _, packet = public_report
detached = deepcopy(packet)
mudda = dig(detached, f"{rf.ANNUAL}.mudda_dasha.data")
for field in ("moon_source", "balance_profile", "balance_method"):
mudda.pop(field, None)
facts = rf._annual(detached)["mudda_dasha"]
assert facts["moon_source"] is None
assert facts["balance_method"] is None
assert facts["periods"]
@@ -0,0 +1,108 @@
"""#192: per-claim evidence-grade ceilings (astrologer 2026-11-12, contract report_evidence_grade_v1)."""
import copy
import re
from pathlib import Path
from scripts.report_render.evidence_grade import CLAIM_TYPES, GRADE_CONTRACT, claim_grade, evidence_grades, grade_allowed
ROOT = Path(__file__).resolve().parents[1]
def _facts(**overrides):
facts = {
"minute_confirmed": True,
"shared_techniques": [{"id": name, "state": "executed"} for name in (
"functional", "d9", "shadbala", "avastha", "dispositor_chain", "ashtakavarga", "argala",
"vimshottari", "narayana", "yogini", "kp",
)],
"domains": {
"career": {"participation": [{"id": i, "state": "executed"} for i in ("D10", "AmK", "AK", "Karakamsha", "A10", "AL", "D1_D9_D10")]},
"wealth": {"participation": [{"id": i, "state": "executed"} for i in ("D2", "D11", "A2", "A11", "D10", "A10")]},
"marriage": {"participation": [{"id": i, "state": "executed"} for i in ("D9_7", "UL", "A7", "DK", "Vivah")]},
"health": {"participation": [{"id": "D6", "state": "executed"}, {"id": "D8", "state": "executed"},
{"id": "D30", "state": "executed"}, {"id": "drekkana22", "state": "executed"},
{"id": "Badhaka", "state": "blocked", "reason_code": "not_computed"},
{"id": "Gulika", "state": "not_in_domain_route"}]},
},
"birth_precision": {"ascendant_margins": {code: {"before_minutes": 30.0, "after_minutes": 30.0}
for code in ("D1", "D2", "D6", "D8", "D9", "D10", "D30")}},
"transits": {"planets": {"Saturn": {"sign": "Pisces"}}, "double_transit": {"checked_houses": [7]}},
"annual": {"target_year": 2026},
"timing": {"narayana": {"status": "partial_verified"}},
"functional": {"natural_vs_functional_conflicts": [{"planet": "Moon", "natural": "benefic", "functional": "malefic"}]},
"blocked_states": {"parameter_sensitive": ["kp"]},
}
facts.update(overrides)
return facts
def test_no_matching_cases_caps_every_available_claim_at_c():
grades = evidence_grades(_facts())
assert grades["contract"] == GRADE_CONTRACT
caps = {name: claim["cap"] for name, claim in grades["claims"].items()}
assert caps == {**{name: "C" for name in CLAIM_TYPES}, "health_obstruction": "D"}
for claim in grades["claims"].values():
assert claim["ledger"]["case_fit"] == "none"
assert claim["ledger"]["external_calculation_check"] == "not_done"
def test_unrelated_missing_layer_does_not_drag_other_claims_to_d():
# Badhaka is blocked: only the claim that needs it is D (her §5.2).
grades = evidence_grades(_facts())["claims"]
assert grades["health_obstruction"]["cap"] == "D"
assert grades["health_pressure"]["cap"] == "C"
assert grades["health_obstruction"]["reasons"] == ["required dependency unavailable: p:health:Badhaka"]
def test_not_routed_and_no_reference_layers_are_not_required():
facts = _facts()
assert all(dep != ("p", "health", "Gulika") for spec in CLAIM_TYPES.values() for dep in spec["required"])
assert claim_grade(facts, "health_pressure")["cap"] == "C"
def test_missing_required_layer_or_boundary_varga_gives_d():
facts = _facts()
facts["domains"]["career"]["participation"][0]["state"] = "blocked"
assert claim_grade(facts, "career_promise")["cap"] == "D"
boundary = _facts()
boundary["birth_precision"]["ascendant_margins"]["D6"] = {"before_minutes": 0.4, "after_minutes": 9.0}
health = claim_grade(boundary, "health_pressure")
assert health["cap"] == "D"
assert health["ledger"]["purpose_stability"] == "boundary_sensitive"
checked_only = _facts()
checked_only["birth_precision"]["ascendant_margins"]["D30"] = {"before_minutes": 0.4, "after_minutes": 9.0}
disclosed = claim_grade(checked_only, "health_pressure")
assert disclosed["cap"] == "C"
assert disclosed["must_disclose"] == ["v:D30"]
def test_unconfirmed_birth_time_is_not_evaluated_not_assumed_stable():
facts = _facts(minute_confirmed=False)
claim = claim_grade(facts, "career_promise")
assert claim["ledger"]["purpose_stability"] == "not_evaluated"
assert claim["cap"] == "C"
def test_parameter_sensitive_timing_is_flagged_not_hidden():
claim = claim_grade(_facts(), "timing_window")
assert claim["cap"] == "C"
assert "s:narayana" in claim["parameter_sensitive"]
def test_grade_allowed_is_at_or_below_cap():
assert grade_allowed("C", "C") and grade_allowed("D", "C")
assert not grade_allowed("B", "C") and not grade_allowed("C", "D")
def test_frontend_claim_types_match_the_backend():
source = (ROOT / "frontend/src/lib/personal-report-reading-contract.ts").read_text(encoding="utf-8")
block = source[source.index("export const REPORT_CLAIM_TYPES"):source.index("] as const;", source.index("export const REPORT_CLAIM_TYPES"))]
assert re.findall(r'"([a-z_]+)"', block) == list(CLAIM_TYPES)
def test_grades_ride_in_the_facts_without_mutating_input():
facts = _facts()
before = copy.deepcopy(facts)
evidence_grades(facts)
assert facts == before
+11 -9
View File
@@ -35,10 +35,10 @@ def test_reading_facts_are_packet_leaves(chart) -> None:
facts = build_report_reading_facts(packet, reading, minute_confirmed=True, today="2026-04-07")
# Building the facts never touches the report's packet.
assert json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str) == before
# 原值:report-reading-facts-v1
# 新值:report-reading-facts-v2
# 原因:产品 2026-11-07 占星师方案:报告完整运行 skill、导出保留审计(reading_facts 契约)
assert facts["schema"] == "report-reading-facts-v2"
# 原值:report-reading-facts-v2
# 新值:report-reading-facts-v3
# 原因:11-12 审核要求报告事实包提供模式、参与原因和计算边界。
assert facts["schema"] == "report-reading-facts-v3"
assert sorted(facts["domains"]) == sorted(READING_DOMAINS)
for name, planet in facts["planets"].items():
assert planet["d1_sign"] == dig(packet, f"core_chart.planets.{name}.sign")
@@ -212,12 +212,14 @@ def test_flag_on_adds_reading_facts_and_keeps_the_report(stubbed) -> None:
)
facts = with_facts.pop("reading_facts")
assert _dumps(with_facts) == _dumps(plain)
# 原值:report-reading-facts-v1
# 新值:report-reading-facts-v2
# 原因:产品 2026-11-07 占星师方案:报告完整运行 skill、导出保留审计(reading_facts 契约)
assert facts["schema"] == "report-reading-facts-v2"
# 原值:report-reading-facts-v2
# 新值:report-reading-facts-v3
# 原因:11-12 审核要求报告事实包提供模式、参与原因和计算边界。
assert facts["schema"] == "report-reading-facts-v3"
assert facts["minute_confirmed"] is True
assert facts["today"] == "2026-04-07"
# 原值:facts["today"]。新值:facts["observation_date"]。
# 原因:v3 更名为观察日期,请求仍用 today,传入的日期值保持相同。
assert facts["observation_date"] == "2026-04-07"
assert "gaps" not in facts