Compare commits

..
Author SHA1 Message Date
Claude e5d6c99dac Merge remote-tracking branch 'origin/staging' into HEAD
Independent Staging Quality Gate / publish (push) Blocked by required conditions
Independent Staging Quality Gate / validate-python (push) In progress
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m40s
2026-11-13 10:15:48 +08:00
jesse-ux 394c39ce33 Add annual audit calculation and interpretation axes (#210)
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m39s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m21s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-13 09:30:04 +08:00
jesse-ux 6bf20b8dbf Sync annual audit worktree with staging for #210 2026-11-13 08:53:20 +08:00
jesse-ux 34f7793e52 docs(annual): record #210 audit appendix compatibility decision 2026-11-12 21:52:19 +08:00
ClaudeandClaude Opus 5.5 b9504d758a docs(task): brief for annual audit two axes and route rows (#210, BUG-1856, 2026-11-12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-12 21:28:58 +08:00
6 changed files with 474 additions and 38 deletions
+14
View File
@@ -25086,6 +25086,20 @@
- 关联记录:#209、#190、BUG-1601、BUG-1650、BUG-1732、BUG-1830、BUG-1842。
- 修复版本:`codex/annual-narayana-conditional-20261112`。
## BUG-1856 | 年运审计计算与解读状态合用,未完成会被现有附录显示为阻塞
- 状态:fixed-pending-verify(后台数据待独立验收,附录接入由 #212 处理)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-13。
- 影响面:年运报告的后台技法审计与现有「技法执行情况」附录。
- 现象:旧审计行的单值区分不了计算与解读。加入拟议的两轴和外部验证行后,现有附录会立即显示这些行,且把 `not_built` 标成「计算阻塞」。
- 触发条件:把新行直接放入既有 `technique_audit` 数组。
- 根因:现有附录直接枚举该数组,只按两个旧名称过滤。计算状态解析的未知值回退到 `blocked`。BUG-1753 与 BUG-1840 的两轴覆盖早于本轮新增的 `not_built`,旧验证未覆盖这一值。
- 修复:按工单评论 42704 新增后台 `technique_audit_axes`,保留旧审计数组。新字段逐行记计算与解读状态、原因、profile 和原始路径,给外部验证四行加附录标记。
- 验证:公开盘核对新增行、状态与方法配置。合成盘旧数组与 staging 逐字节一致,十个写作区段的证据一致。详见 `docs/tasks/PROGRESS-annual-audit-axes-20261112.md`。
- 防复发:锁定旧数组完整内容,检查计算阻塞和未完成的不同状态,并以写作入口验证新字段不进入模型输入。
- 关联记录:#210、#212、BUG-1753、BUG-1794、BUG-1840、BUG-1855。
- 修复版本:`codex/annual-audit-axes-20261112`,待独立验收。
## BUG-1858 | 报告用途稳定性由一分钟分盘边界检查推定
- 状态:fixed-pending-verify(待独立验收)。
@@ -0,0 +1,46 @@
# 年运审计两轴 · 2026-11-12
Gitea #210,BUG-1856。按 Claude 评论 42309 的任务书认领,认领评论 42331。基线 `2f374fff281622f342151a97fa6f6d758de74cc8`。
## 已核对
- 年运事实包的 `technique_audit` 目前既是后台审计数组,也是现有年运附录「技法执行情况」的数据源。`buildAppendix` 只按两个旧技法名排除外部行,会渲染数组里的其他行。`visible_in_appendix` 当前没有参与过滤。
- `techniqueAuditAxes` 已读取两轴字段。现有 `parseTechniqueCalculationStatus` 没有 `not_built` 枚举,未知值落到 `blocked`。
- `#212` 的可信评论 42236 指明新「计算依据」附录显示外部验证,正文仍不写。该工单仍排队,等 #210 等前置单合入。
- 在与 staging 一致的年运附录和写作投影代码中,给现有事实夹具注入四条拟议外部行和一条 Chara Dasha 示例。四条外部行均进入当前附录,三条 `not_built` 被显示成「计算阻塞」。十个写作区段的证据文本均未带入这四行。注入仅用于复现显示,示例状态不代表真实计算。
- 现有 Python 年运审计合同 1 项通过,年运前端定向 36 项通过。复现实验及合同检查未修改产品代码。
## 已确认的交付契约
Claude 在评论 42704 放行独立字段,第二次认领为评论 42726。执行分支合入了 staging `7670cf54`,冲突处保留 BUG-1856 和 staging 的其他记录。新字段为 `technique_audit_axes`。原 `technique_audit` 保持行数、顺序及取值。附录切换和「未完成」显示由 #212 处理。
## 路由核对与状态
对照 `references/strict-workflow-router.md` §0.7 的印度与西方技法、§8 的相关年运行,以及 `references/product_runtime_contract.md` 的两轴和外部验证约定。状态随实际块结果更新。已计算的年盘块缺部分年份时写 `partial`,同时记录缺项原因。计算失败的层给出阻碍,未接入的层写 `not_built`。未计算的层无原始结果路径。
| 技法 | 计算 / 解读 | 证据与边界 |
| --- | --- | --- |
| D1、D2、D4、D9、D10、D24、Functional Benefic/Malefic | executed / primary | 本命与分领域分盘。功能吉凶来自实际 profile。 |
| D11、A10、A7、UL、DK、Natal Yogas | executed / supporting | D11 是财富辅助盘,A10、A7、UL、DK 与本命瑜伽按领域作为辅助依据。 |
| Jaimini / Arudha | partial / supporting | 上述专用点有结果,完整套件未跑。 |
| Vimshottari、Transits | executed / primary | 本命时间轴与慢星行运;上游计算失败时保留 blocked 和原因。 |
| Narayana Dasha、Varsha Narayana、Annual Sahams、Vedha | executed / conditional | 来源版本、年盘太阳角距、回归昼夜公式和慢星 Vedha 分开记;本命 Narayana 依 #209 作条件对照,计算被阻塞时保留阻碍。 |
| Tajika Annual Chart、Mudda Dasha、Patyayini Dasha | executed / primary | 年盘及其中的年内运限。 |
| Muntha、Tajika Year Lord、Panchavargiya Bala、Annual Shadbala、Transit Ashtakavarga | executed / supporting | 年盘位置、年主、年盘力量及慢星位置的本命 BAV/SAV。 |
| Sade Sati、Tajika Aspects | executed / observation_only | 保留土星相对月亮的位置与相位联系。 |
| Tajika Yogas | executed 或 partial / not_used | 从具名瑜伽块读取结果;存在缺条件的具名规则时记 partial。 |
| Tajika Monthly Charts、Tithi Pravesha、Tripataki Chakra、Annual Special Points、Mudda Alternative Profiles | executed / not_used | 计算保留在年包或后台卡片;不送进写作区段。替代 Mudda 口径与写作用的默认序列分别列出。 |
| Chara、Yogini、Ashtottari、Kalachakra、Bhrigu Pada、KP、Double Transit PAC、Transit LL / 7L、Bhava Chalit、Sudarshana、Nakshatra Overlay、Natal Shadbala、Argala | not_built / not_used | 年包未执行这些专用层,分行说明当前覆盖范围。 |
| 回归黄道本命盘、西占行运持续区间、西占太阳回归、次限、太阳弧、逆向推运或太阳弧、中点、月亮回归、Parans | not_built / not_used | 年运链路没有运行这些西方方法。 |
| Yoga / Nakshatra Pravesha | not_applicable / not_used | 目前只用于研究,已在年运范围中排除。 |
| MEVG / Global Web Evidence、Real Case Calibration | not_built / not_used | 保留旧名称对应的审计行,记录本轮没有执行。 |
| 本地计算、预置来源匹配、实时联网、真实案例校准 | 本地按计算状态;其余 not_built / not_used | 四行都有附录标记。预置来源匹配证据不足,实时联网和案例校准本轮未做。 |
公开回归使用仓库收录的 Barack Obama AA 记录。两轴状态按实际返回,保留该盘中本命年运数据的阻碍以及具名 Tajika 瑜伽的部分覆盖。回归同时用合成盘核对 staging 的旧数组字节和全部十个写作区段的证据投影。没有改动既有断言。
## 验证
- 新增四项回归、现有后台事实测试合计 18 项通过。现有前端年运定向 36 项通过、取消 0。隐私检查 62 项通过。
- 年运专项套件 163 项通过、5 项失败。用未改动的 staging `7670cf54` 单独复现同名五项,涉及 PyJHora 年运参考、报告参考行和既有年运时间轴连续性。
- Python 快速门 1,463 项通过、2 跳过、3 项 PyJHora 失败,失败项与同机基线一致。最新 staging `3e6077f75` 已合入 #228,本单年运范围的文件未受影响。
- 开工预检的碎片扫描、外部适配器和远端可见性通过;预检定向套件仍有一个要求本机存在旧镜像路径的失败,错误台账已有记录。
@@ -0,0 +1,94 @@
# TASK:年运报告技法审计改为两个状态轴,并补齐路由技法行(后台,2026-11-12)
- Gitea:#210
- 分支:`codex/annual-audit-axes-20261112`(基于 staging `2f374fff281622f342151a97fa6f6d758de74cc8`,#209 已合入)
- Bug 编号:BUG-1856。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
占星师有以下几条裁定:
- **第五轮裁定 7(2026-11-08)**:审计表每行分两个状态轴写。
- Calculation:`executed` / `partial` / `blocked` / `not_applicable`
- Interpretation:`primary` / `supporting` / `conditional` / `observation_only` / `blocked`,再加产品自己的 `not_used`
- 个人报告和聊天已经这样做了,见 BUG-1753,以及 `references/product_runtime_contract.md` 第五轮裁定 7 一条。
- **她的路由 `references/strict-workflow-router.md` 第 70–77 行(Full-Spectrum 合同)**:每个相关技法都要写明 executed、blocked 或 not_applicable,不得静默省略。路由第 365 行要求 Tajika Yogas 单独一行。
- **Q3(2026-11-07)**:交付了文件,不等于执行了方法。
年运报告现在的情况(行号是大约位置):
- `scripts/annual_service/report_facts.py` 的 `_technique_audit`(约 :1581–1629)每行只有一个状态。
- 月盘、Tithi Pravesh 算了却没有被读用,审计里仍写“executed”,约 :1613。
- 路由要求的一些技法没有行,例如 Double Transit、Chara、Yogini、Ashtottari、Kalachakra、KP。
- 没有单独的 Tajika Yogas 行。
**本单只改后台数据,也就是审计行的结构和内容。页面怎么显示另开单,等产品负责人看过原型图再定。**
## 要做的
1. `_technique_audit` 每一行增加 `calculation_status` 和 `interpretation_status`,取值按上面的两轴。保留原来的单值字段,前端现在还在读它。
2. 新旧值映射照 `references/product_runtime_contract.md` 第五轮裁定 7 一条的写法:
- executed → executed + primary
- candidate → executed + conditional
- blocked → blocked + blocked
- not_applicable → not_applicable + not_used
3. **算了但没读用的层**,即月盘、Tithi Pravesh,以及其他符合这个情况的层,标 `executed` + `not_used`。所有这类层逐个列进 PROGRESS。
4. 新增单独的 **Tajika Yogas** 行,从现有的具名瑜伽结果取状态。
5a. **「未完成」与「阻塞」分开**(占星师 2026-11-12 验收回复 §4、§5 Q3):Calculation 轴新增 `not_built`(中文「未完成」)——技法相关、但网站尚未开发或这次没有运行。`blocked` 只用于有具体阻碍(缺输入、调用失败、验证失败),并写明阻碍;`not_applicable` 必须写明适用条件或范围理由。`not_in_domain_route` 不能自动当作不适用。
5. **路由技法补行**:
- 对照她的路由第 70–77 行,以及年运相关路由,把没有行的技法补上;
- 年运报告没有运行的技法,按 5a 写 `not_built`、`blocked` 或 `not_applicable`,并给出原因;不能写 executed;
- 不新增任何计算,本单只写行。
6. 每行写上实际用的 profile 名,例如 `pvr_table30_v1`、Narayana 的 Rath profile,以及原始数据路径。没有就写 `null`。
7. **外部验证四行**:「本地计算」「预置来源匹配」「实时联网」「真实案例校准」。
- 写进审计数据,取值照实(本轮「实时联网」「真实案例校准」为未完成);
- 不进给模型的正文要求(模型正文仍不写,BUG-1794);
- 产品负责人 2026-11-12 决定(回应占星师验收回复 §4):这几行要在用户可展开的「计算依据」附录里如实显示。本单只保证数据里有、并标 `visible_in_appendix: true`;页面显示由 #212 做。
8. Narayana 一行的 Interpretation 写 `conditional`。这一点与 #209 一致。
## 要改的文件
- `scripts/annual_service/report_facts.py`:只改 `_technique_audit` 及其辅助函数。
- 新建 `tests/test_annual_audit_axes_20261112.py`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-annual-audit-axes-20261112.md`
## 不要碰
- `frontend/**`:页面显示另开单。
- 冻结文件,以及 `references/**`。
- `report_facts.py` 里窗口、阶段和 Narayana 的逻辑:属于 #209。
## 验收标准(写进新测试)
1. 用一张公开名人盘生成年包:每一行审计都有两个轴,取值在规定范围内。
2. 月盘、Tithi Pravesh 两行是 `executed` + `not_used`。
3. 有 Tajika Yogas 行。
4. 路由列出的每个技法都有一行,没有静默缺失。测试里列出期望的技法名单,名单来自你对照路由整理的结果。
5. 外部验证四行在审计数据里存在、带 `visible_in_appendix: true`,并且不出现在给模型的证据文本里。
6. 现有年运测试全部通过。
## 自测命令
```
python -m pytest -q tests/test_annual_audit_axes_20261112.py tests/ -k annual
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-annual-audit-axes-20261112.md`,内容包括:
- 对照路由整理的技法清单,以及每一项的两轴取值和原因;
- “算了没读用”的层的清单;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- 某个技法该写 `not_built`、`blocked` 还是 `not_applicable`,路由和裁定里都没说清。
- 要让某一行如实反映状态,必须新增计算。
@@ -1,38 +0,0 @@
# TASK:最后仍不通过的节,改用计算直接生成的内容;页面不再出现「没有通过核对」(2026-11-13)
- Gitea:#243
- 分支:`codex/reading-computed-fallback-20261113`。**开工条件:#242(重绑与重写)合入 staging**,开工前先 rebase。
- Bug 编号:BUG-1877
- 验收:Claude(jyotisha-e6)
不得自行写「产品决定/裁定」,任务书没写到的取舍一律选需拍板。不得修改或删除既有断言来让测试通过;确需修改的,写明原值/新值/原因。
## 产品决定(产品负责人 2026-11-13)
一节经过系统修正标注、模型重写一次之后仍不通过时,页面上这一节显示**由计算直接生成的内容**,不再写「这一节没有通过数据核对」,也不写原因。原因只留在后台的 `transform_log` 里。「依据与边界」被拦时生成的依据清单(#218),也并入这套做法。
## 要做的
1. **每节的计算兜底**:放在新文件 `frontend/src/lib/personal-report-reading-fallback.ts`,只读事实包,不调用模型。每节 2–4 段,句子固定,只陈述盘面事实;每段按所属结论类型的上限标等级(C 或 D)。
- 性格:上升星座与命宫星宿;命主星落宫与尊贵;月亮落宫与尊贵;功能吉星、凶星(按 `functional.functional_profile`)。
- 事业:第十宫星座、宫主落宫与尊贵;D10 上升(D10 在边界上时只写「出生时间误差内会换座,暂不判断」);A10、AmK。
- 财富:第二宫、第十一宫的星座、宫主落宫;D2 上升(处理同上);第二宫、第十一宫的 SAV 分数(只报分数,不写强弱)。
- 感情:第七宫星座、宫主落宫与尊贵;D9 上升(处理同上);UL、DK。
- 健康:第六、第八、第十二宫的宫主落宫;Badhaka 宫和 Badhaka 宫主(标注「产品计算,待占星师复核」);不写病症。
- 现在这几年:Vimshottari 当前的大运、小运、细运起止;Narayana 当前段(标注「参数敏感,只作条件窗口」)。
- 总评:取性格节第一段,再加各领域第一句。
- 几条建议:**整节不显示**。建议没法由计算生成;这节失败时,目录里也去掉它。
- 依据与边界:沿用 #218 的依据清单。
文案风格:短句,中文,术语第一次出现时用半句话说明它看什么,与模型稿保持一致。
2. **存储**:兜底的节记为 `status: "computed"`,段落带 `source: "computed"` 和 `grade`。存储版本加一,旧版本照常读取。
3. **页面与导出**:`computed` 的节和普通的节显示方式一致,段落下面照样有等级行。删去对用户显示的 `READING_SECTION_WITHHELD_TEXT` 和 `WITHHELD_REASONS`;旧存储里已有的 withheld 节,读取时也改用兜底生成(读取时手里有事实包就生成,没有就整节不显示)。
4. **验收工具**:`postcheck.json` 统计兜底节数。
## 验收
1. 合成事实包:每节都能生成兜底;D10、D2、D9 在边界上时,相应句子写成「暂不判断」;健康节没有病症词(跑一遍 #218 的 health_outcome 检查,必须通过)。
2. 页面和 MD 导出里,不再出现「没有通过数据核对」,也不出现任何退回原因。
3. 旧 v3/v4 存储里的 withheld 节能正常显示(兜底或不显示)。
4. 原有测试全部通过;tsc 无错误、eslint 0 个错误,Python 快速门通过。
5. 截图(手机宽度):兜底的事业节和普通节放在一起的效果,附在 PR 里。
+194
View File
@@ -1654,6 +1654,199 @@ def _technique_audit(package: dict, natal: dict) -> list[dict[str, str]]:
return rows
_AUDIT_AXES = {
"computed": ("executed", "primary"),
"executed": ("executed", "primary"),
"candidate": ("executed", "conditional"),
"parameter_sensitive": ("executed", "conditional"),
"partial": ("partial", "conditional"),
"blocked": ("blocked", "blocked"),
"not_applicable": ("not_applicable", "not_used"),
"not_built": ("not_built", "not_used"),
}
def _audit_axes_row(name, status, note, *, raw_path=None, profile=None, interpretation=None):
calculation, usage = _AUDIT_AXES[status]
if calculation in ("executed", "partial") and interpretation is not None:
usage = interpretation
return {
"technique": name,
"calculation_status": calculation,
"interpretation_status": usage,
"note": note,
"profile": profile,
"raw_path": raw_path,
}
def _audit_axes_from_block(name, block, note, path, *, profile=None, interpretation=None):
status = block.get("status")
if status not in _AUDIT_AXES:
status = "blocked"
reason = block.get("reason")
if reason or status in ("blocked", "not_applicable"):
note = f"{note};{reason or '本轮缺少对应的计算结果'}"
return _audit_axes_row(name, status, note, raw_path=path if block else None,
profile=profile or block.get("profile"), interpretation=interpretation)
def _technique_audit_axes(package: dict, natal: dict) -> list[dict[str, Any]]:
"""Project existing results into the appendix's two axes without rerunning techniques."""
natal_ready = natal.get("status") == "computed"
periods = package.get("natal_periods") or {}
narayana = periods.get("narayana") or {}
transit = package.get("transit_context") or {}
annual_views = package.get("independent_annual_views") or {}
rows = []
def add(name, block, path, note, **options):
rows.append(_audit_axes_from_block(name, block or {}, note, path, **options))
functional = natal.get("functional") or {}
add("Functional Benefic/Malefic", functional, "natal_base.functional",
"本命功能吉凶用于各领域判断" if natal_ready else natal["reason"], profile=functional.get("functional_profile"))
add("Vimshottari", periods.get("vimshottari"), "package.natal_periods.vimshottari",
"大运作背景,小运与细运用于分领域判断")
method = CONFIG["narayana_skeleton_profile"]
add("Narayana Dasha", narayana.get(method) or {"reason": narayana.get("reason")},
f"package.natal_periods.narayana.{method}" if narayana.get(method) else None,
"Sanjay Rath 版用于条件对照,旧算法只作对照",
profile=CONFIG["narayana_method"].get("profile"), interpretation="conditional")
add("Transits", package.get("slow_transits"), "package.slow_transits", "慢星换宫、停滞与精确合相")
for name, key, usage, note in (
("Sade Sati", "sade_sati", "observation_only", "土星相对本命月亮的位置"),
("Transit Ashtakavarga", "ashtakavarga", "supporting", "慢星所在座的本命 BAV 与 SAV"),
("Vedha", "vedha", "conditional", "只核对慢星 Vedha"),
):
block = transit.get(key)
add(name, block or {"reason": transit.get("reason")}, f"package.transit_context.{key}" if block else None,
note, interpretation=usage)
charts = package.get("year_charts") or []
annual_blocks = (
("Tajika Annual Chart", "chart", "primary", "太阳回归年盘"),
("Muntha", "muntha", "supporting", "年盘 Muntha 及其宫主"),
("Tajika Year Lord", "year_lord", "supporting", "Panchadhikari 年主"),
("Tajika Aspects / Panchavargiya Bala", "annual_strength", "supporting", "五分力用于评估力量,Tajika 相位另列"),
("Tajika Aspects", "tajika_yogas", "observation_only", "只记录 Deeptamsa 内的相位联系"),
("Tajika Yogas", "tajika_yogas", "not_used", "具名瑜伽只列出,部分规则的必要条件未齐"),
("Annual Sahams", "sahams", "conditional", "按回归时刻的昼夜取 PVR 表 74 公式"),
("Annual Shadbala", "annual_shadbala", "supporting", "年盘力量,独立于本命 Shadbala"),
("Varsha Narayana", "varsha_narayana", "conditional", "年盘太阳角距版"),
("Mudda Dasha", "mudda_dasha", "primary", "按年盘解释"),
("Patyayini Dasha", "patyayini", "primary", "按年盘解释"),
("Tajika Monthly Charts", "monthly_charts", "not_used", "计算月盘,年运写作与候选窗口均未使用"),
("Tripataki Chakra", "tripataki", "not_used", "年包计算,年运写作未读取"),
("Annual Special Points", "special_points", "not_used", "年盘特殊点已计算,年运写作未读取"),
("Mudda Alternative Profiles", "mudda_profiles", "not_used", "年包保留并列口径,年运写作仍使用默认序列"),
)
for name, key, usage, note in annual_blocks:
blocks = [(chart.get("blocks") or {}).get(key) or {"reason": chart.get("reason")} for chart in charts]
successful = [block for block in blocks if block.get("status") == "computed"]
if successful:
representative = successful[-1]
data = representative.get("data") or {}
partial = len(successful) < len(blocks) or (key == "tajika_yogas" and
any((block.get("data") or {}).get("blocked_named_yogas") for block in successful))
block = {**representative, "status": "partial" if partial else "computed"}
if len(successful) < len(blocks):
gaps = dict.fromkeys(b.get("reason") or "缺少计算结果" for b in blocks if b.get("status") != "computed")
note += ";部分年盘未完成:" + "、".join(gaps)
profile = representative.get("balance_profile") or representative.get("default_profile") or data.get("profile")
if key == "year_lord":
profile = "Panchadhikari"
else:
block, profile = (blocks[-1] if blocks else package.get("report_range") or {}), None
raw_path = f"package.year_charts[*].blocks.{key}" if any(key in (chart.get("blocks") or {}) for chart in charts) else None
add(name, block, raw_path, note, profile=profile, interpretation=usage)
tithi = annual_views.get("tithi_pravesha") or {}
tithi_years = tithi.get("years") or []
tithi_ready = sum(year.get("status") == "computed" for year in tithi_years)
if tithi_years and tithi_ready < len(tithi_years):
tithi = {**tithi, "status": "partial" if tithi_ready else "blocked",
"reason": "、".join(dict.fromkeys(year.get("reason") or "缺少候选" for year in tithi_years
if year.get("status") != "computed"))}
add("Tithi Pravesha", tithi, "package.independent_annual_views.tithi_pravesha",
"独立年度视角,未纳入年运写作和候选窗口", interpretation="not_used")
natal_path = "natal_base" if natal_ready else None
rows.append(_audit_axes_row("D1", natal["status"], "本命盘及宫位" if natal_ready else natal.get("reason"),
raw_path=natal_path, profile=(package.get("parameters") or {}).get("house_system")))
for domain, divisions in DOMAIN_VARGAS.items():
for index, division in enumerate(divisions):
usage = "supporting" if VARGA_ROLES.get((domain, division)) == "auxiliary" else "primary"
rows.append(_audit_axes_row(
f"D{division}", natal["status"], f"本命 {domain} 分盘" if natal_ready else natal.get("reason"),
raw_path=f"natal_base.domains.{domain}.vargas[{index}]" if natal_ready else None, interpretation=usage,
))
for name, domain in (("A10", "career"), ("A7", "relationship"), ("UL", "relationship"), ("DK", "relationship")):
special = (natal.get("domains") or {}).get(domain, {}).get("special") or {}
rows.append(_audit_axes_row(
name, "executed" if special.get(name) else "blocked",
"分领域辅助核对" if special.get(name) else f"本轮缺少 {name} 计算结果",
raw_path=f"natal_base.domains.{domain}.special.{name}" if special.get(name) else None,
interpretation="supporting",
))
add("Natal Yogas", natal.get("yogas"), "natal_base.yogas",
"本命候选瑜伽" if natal_ready else natal["reason"], interpretation="supporting")
for name, note in (
("Chara Dasha", "年包只计算 Vimshottari 和 Narayana 本命运限"),
("Yogini Dasha", "年包未运行 Yogini 运限"),
("Ashtottari Dasha", "年包未核对适用条件或运行 Ashtottari 运限"),
("Kalachakra Dasha", "年包未运行 Kalachakra 运限"),
("Bhrigu Pada", "本命年运证据未接入 Bhrigu Pada 计算"),
("KP / Sub-lord", "年运未计算 KP 宫头与子主"),
("Double Transit PAC", "现有慢星行运与相位未执行 PAC 条件核对"),
("Transit LL / 7L", "未单独追踪上升主与七宫主的行运窗口"),
("Bhava Chalit", "年运本命宫位采用整宫,未另算 Bhava Chalit"),
("Sudarshana", "年运未运行 Sudarshana 多参考点"),
("Nakshatra Overlay", "本命月宿字段已保存,未运行叠加核对"),
("Natal Shadbala", "已计算年盘 Shadbala,未单独计算本命 Shadbala"),
("Argala", "年运未执行 Argala 支持与阻碍核对"),
("Western Tropical Natal Chart", "年运链路未接入回归黄道本命盘"),
("Western Transit-to-Natal Duration Scan", "未运行西占行运持续区间扫描"),
("Western Solar Return", "已计算恒星黄道 Tajika 年盘,未运行西占回归盘"),
("Secondary Progressions", "年运未运行次限"),
("Solar Arc", "年运未运行太阳弧"),
("Converse Progressions / Arcs", "年运未运行逆向推运或逆向太阳弧"),
("Midpoints", "年运未运行西占中点"),
("Lunar Return", "已计算 Tajika 月盘,未运行西占月亮回归"),
("Parans", "年运未运行同升同落核对"),
("MEVG / Global Web Evidence", "本轮未运行外部实时联网"),
("Real Case Calibration", "本轮未运行独立案例校准"),
):
rows.append(_audit_axes_row(name, "not_built", note))
rows.append(_audit_axes_row("Jaimini / Arudha", "partial" if natal_ready else "blocked",
"已单列 A10、A7、UL、DK;未运行完整 Jaimini / Arudha 组" if natal_ready else natal["reason"],
raw_path="natal_base.domains" if natal_ready else None, interpretation="supporting"))
rows.append(_audit_axes_row("Yoga / Nakshatra Pravesha", "not_applicable", "仅研究使用,现行年运范围排除"))
calculated_charts = sum(chart.get("status") == "computed" for chart in charts)
local_status = "blocked"
core_ready = natal_ready and (package.get("parameters", {}).get("natal_chart") or {}).get("status") == "computed"
if core_ready and calculated_charts and calculated_charts == len(charts):
local_status = "executed"
elif natal_ready or calculated_charts:
local_status = "partial"
local_note = "本命与年盘的本地计算状态"
if local_status != "executed":
gaps = [chart.get("reason") for chart in charts if chart.get("status") != "computed"]
reasons = dict.fromkeys(reason for reason in [natal.get("reason"), *gaps] if reason)
local_note += ";" + ("、".join(reasons) or "缺少可用年盘")
external = (
("本地计算", local_status, local_note),
("预置来源匹配", "not_built", "年运报告未接入预置来源匹配,当前证据不足"),
("实时联网", "not_built", "本轮未执行实时联网"),
("真实案例校准", "not_built", "本轮未执行独立案例校准"),
)
for name, status, note in external:
row = _audit_axes_row(name, status, note, raw_path="package.block_index" if name == "本地计算" else None,
interpretation="not_used")
rows.append({**row, "visible_in_appendix": True})
return rows
def build_annual_report_facts(request: dict[str, Any], *, birth_window: Any = None) -> dict[str, Any]:
package = ap.build_annual_package(request)
ctx = ap._Context(request)
@@ -1681,6 +1874,7 @@ def build_annual_report_facts(request: dict[str, Any], *, birth_window: Any = No
"natal_base": public_natal,
"cards": cards,
"technique_audit": audit,
"technique_audit_axes": _technique_audit_axes(package, natal),
# 占星师第三轮审查 2026-11-07:起点含、终点不含;跨年 / 跨表的断口与重叠。
"timeline_continuity": _timeline_summary(package.get("timeline_continuity")),
})
+126
View File
@@ -0,0 +1,126 @@
"""Annual audit axes and compatibility with the existing reader and writer."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
import hashlib
import json
from pathlib import Path
import subprocess
import pytest
from scripts.annual_service import api
from scripts.annual_service.report_facts import _technique_audit_axes
ROOT = Path(__file__).resolve().parents[1]
FRONTEND = ROOT / "frontend"
TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs"
CALCULATION_STATES = {"executed", "partial", "blocked", "not_applicable", "not_built"}
INTERPRETATION_STATES = {"primary", "supporting", "conditional", "observation_only", "blocked", "not_used"}
EXTERNAL = {"本地计算", "预置来源匹配", "实时联网", "真实案例校准"}
# Full-Spectrum Invocation Contract §0.7 and Technique Audit Table §8.
ROUTE_TECHNIQUES = {
"D1", "D2", "D4", "D9", "D10", "D11", "D24", "Functional Benefic/Malefic",
"Vimshottari", "Narayana Dasha", "Chara Dasha", "Yogini Dasha", "Ashtottari Dasha",
"Kalachakra Dasha", "Bhrigu Pada", "Jaimini / Arudha", "A10", "A7", "UL", "DK",
"KP / Sub-lord", "Double Transit PAC", "Transit LL / 7L", "Bhava Chalit", "Sudarshana",
"Tajika Annual Chart", "Muntha", "Tajika Year Lord", "Nakshatra Overlay",
"Natal Shadbala", "Annual Shadbala", "Transit Ashtakavarga", "Natal Yogas", "Argala",
"Tajika Yogas", "Tajika Aspects", "Annual Sahams", "Transits", "Sade Sati", "Vedha",
"Western Tropical Natal Chart", "Western Transit-to-Natal Duration Scan", "Western Solar Return",
"Secondary Progressions", "Solar Arc", "Converse Progressions / Arcs", "Midpoints",
"Lunar Return", "Parans", "MEVG / Global Web Evidence", "Real Case Calibration",
}
@pytest.fixture(scope="module")
def public_facts():
source = ROOT / "references/real_case_calibration/minute_rectification_holdout_v4.json"
cases = json.loads(source.read_text(encoding="utf-8"))["cases"]
birth = next(row["birth"] for row in cases if row["case_id"] == "barack_obama_1961_aa_v4_holdout")
dt = datetime.fromisoformat(f"{birth['date']}T{birth['time']}")
return api.compute_annual_report_facts({
"year": dt.year, "month": dt.month, "day": dt.day, "hour": dt.hour, "minute": dt.minute,
"lat": birth["latitude"], "lon": birth["longitude"], "tz": birth["timezone_offset"],
"timezone_id": "Pacific/Honolulu", "report_range": {"kind": "birthday_year", "year": 2026},
"observation_date": "2026-11-01",
})["facts"]
def test_annual_audit_lists_route_techniques_and_distinguishes_unused_from_blocked(public_facts):
rows = public_facts["technique_audit_axes"]
by_name = {row["technique"]: row for row in rows}
assert len(by_name) == len(rows)
assert ROUTE_TECHNIQUES | EXTERNAL <= by_name.keys()
for row in rows:
assert row["calculation_status"] in CALCULATION_STATES
assert row["interpretation_status"] in INTERPRETATION_STATES
assert row["note"]
assert row["profile"] is None or isinstance(row["profile"], str)
assert row["raw_path"] is None or isinstance(row["raw_path"], str)
for name in ("Tajika Monthly Charts", "Tithi Pravesha", "Tripataki Chakra", "Annual Special Points", "Mudda Alternative Profiles"):
assert (by_name[name]["calculation_status"], by_name[name]["interpretation_status"]) == ("executed", "not_used")
assert by_name["Functional Benefic/Malefic"]["profile"] == "pvr_table30_v1"
assert by_name["Tajika Yogas"]["raw_path"] == "package.year_charts[*].blocks.tajika_yogas"
assert by_name["Tajika Yogas"]["interpretation_status"] == "not_used"
assert by_name["Double Transit PAC"]["calculation_status"] == "not_built"
assert by_name["Yoga / Nakshatra Pravesha"]["calculation_status"] == "not_applicable"
assert {row["technique"] for row in rows if row.get("visible_in_appendix")} == EXTERNAL
assert by_name["实时联网"]["calculation_status"] == "not_built"
assert by_name["真实案例校准"]["calculation_status"] == "not_built"
def test_annual_audit_carries_source_status_and_reason_on_partial_or_blocked_blocks():
package = {
"parameters": {"natal_chart": {"status": "computed"}},
"natal_periods": {"narayana": {"rath": {"status": "computed"}}},
"year_charts": [
{"status": "computed", "blocks": {"tajika_yogas": {"status": "computed"}}},
{"status": "blocked", "reason": "solar_return_unavailable"},
],
"transit_context": {"status": "blocked", "reason": "natal_moon_missing"},
}
by_name = {row["technique"]: row for row in _technique_audit_axes(package, {"status": "computed"})}
assert by_name["Narayana Dasha"]["interpretation_status"] == "conditional"
assert by_name["Narayana Dasha"]["profile"]
assert by_name["Tajika Yogas"]["calculation_status"] == "partial"
assert "solar_return_unavailable" in by_name["Tajika Yogas"]["note"]
assert by_name["Sade Sati"]["calculation_status"] == "blocked"
assert by_name["Sade Sati"]["raw_path"] is None
assert "natal_moon_missing" in by_name["Sade Sati"]["note"]
assert by_name["本地计算"]["calculation_status"] == "partial"
def test_annual_legacy_audit_matches_staging_byte_for_byte():
# Capture from the unchanged staging implementation with its fictional fixture.
from tests.test_annual_report_facts import _facts
legacy = json.dumps(_facts()["technique_audit"], ensure_ascii=False, separators=(",", ":")).encode()
assert hashlib.sha256(legacy).hexdigest() == "8cfbaba4cfb4aef84eb1913b4bc88780c48aefa258bd3fd4bcdaab84fd3a531f"
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
def test_annual_writer_evidence_does_not_read_the_new_audit(public_facts):
without_axes = deepcopy(public_facts)
without_axes.pop("technique_audit_axes")
program = """
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { EMPTY_ANNUAL_SITUATION } from "./src/lib/annual-report-shared.ts";
import { buildAnnualPlan, sectionEvidence } from "./src/lib/annual-report-sections.ts";
const [before, after] = JSON.parse(readFileSync(0, "utf8"));
const plan = buildAnnualPlan(after, EMPTY_ANNUAL_SITUATION);
assert.equal(plan.length, 10);
const evidence = (facts) => plan.map((entry) => sectionEvidence(facts, entry, EMPTY_ANNUAL_SITUATION));
assert.deepEqual(evidence(before), evidence(after));
assert.doesNotMatch(JSON.stringify(evidence(after)), /technique_audit_axes|本地计算|预置来源匹配|实时联网|真实案例校准/);
"""
subprocess.run(
["node", str(TSX), "-e", program], cwd=FRONTEND,
input=json.dumps([without_axes, public_facts], ensure_ascii=False).encode("utf-8"),
check=True, timeout=45,
)