Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d6c99dac | ||
|
|
151159927b | ||
|
|
ef952e00fa | ||
|
|
394c39ce33 | ||
|
|
dad8617fd7 | ||
|
|
6aa8552692 | ||
|
|
55272038c1 | ||
|
|
3b35f810ed | ||
|
|
c1fe0f8d52 | ||
|
|
6bf20b8dbf | ||
|
|
848698de40 | ||
|
|
a180955af5 | ||
|
|
cd1794642e | ||
|
|
34f7793e52 | ||
|
|
b9504d758a |
@@ -11,6 +11,13 @@
|
||||
|
||||
- ~~模型稿待回放。~~ Claude 已回放,结果见 [#219 修复单](docs/tasks/FIX-reading-claim-binding-1.md)。修复后的重新生成与回放由独立验收执行。
|
||||
|
||||
## BUG-1863 · 已存报告读取缺少当前事实包(#220,2026-11-13)
|
||||
|
||||
- 报告页和导出共用已存解读。生成时的完整事实包未落入报告快照,读路径也没有取得当前事实包的入口。
|
||||
- 替代证据:已核对生成、快照存储、报告读取和导出四处源码,见 [#220 进度](docs/tasks/PROGRESS-reading-snapshot-binding-20261112.md)。
|
||||
- ~~#220 因此暂停实现。~~ 修订任务书已把读时复查拆为后续工单,生成快照、转换记录和离线工具可以独立交付。
|
||||
- 模型验收:本机缺受控 DeepSeek 凭据和三份归档样稿。替代证据为合成草稿的本地引擎重算与离线回放,由 Claude 在验收主机复验在线模式和归档草稿。
|
||||
|
||||
## TASK-voice-reading-20261027:真实模型输出没看(issue #113,2026-10-27)
|
||||
|
||||
- **报告解读**:本机没有 `DS_KEY`、`BACKTEST_MODEL_KEY`、`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`。解读的形状、校验、失败兜底、记账都用假模型测过;真实模型写出来的解读是什么样、几成能一次过校验、耗时多少,都没测,不得写成通过。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-13 — 个人报告显示章节拦截原因
|
||||
|
||||
- 页面和 Markdown 导出会解释对应章节为何未通过核对。新解读保存生成快照和逐节转换记录,便于复查和回放。
|
||||
- 沿用当前 Skill 版本(BUG-1863)。
|
||||
|
||||
## 2026-11-13 — 个人报告逐段核对结论与依据
|
||||
|
||||
- 总评和建议沿用正文通过核对的结论等级。含时间窗口或引用分盘的判断按所用依据复查;页面和 Markdown 标明综合了几类结论。
|
||||
|
||||
@@ -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(待独立验收)。
|
||||
@@ -25159,6 +25173,19 @@
|
||||
- 关联记录:#219、BUG-1860、BUG-1864、BUG-1858。
|
||||
- 修复版本:`codex/reading-claim-binding-20261112`,待发布。
|
||||
|
||||
## BUG-1863 | 个人报告解读缺少生成快照绑定和读时复查
|
||||
|
||||
- 状态:fixed-pending-verify(生成绑定与转换记录已修复,读时复查已拆单)。
|
||||
- 首次发现 / 最近更新:2026-11-12 / 2026-11-13。
|
||||
- 影响面:个人报告解读、页面和 Markdown 导出。
|
||||
- 现象:生成后的事实包发生状态变化时,已存解读继续按旧结果展示,验收包也无法核对草稿和成稿之间的字段转换。
|
||||
- 触发条件:报告解读已生成,随后事实包重建或依赖状态变化,再次打开或导出已存报告。
|
||||
- 根因:完整事实包只在生成时传入解读写作器。持久化快照保存解读及展示数据,报告读取直接返回该解读,导出复用同一结果。
|
||||
- 修复:新解读保存输入、事实包、证据等级、系统提示、所选原始回复与存储结果的哈希,另存逐次尝试的章节结果和字段变化。报告页和 Markdown 显示同一条拦截原因。离线工具可用当前事实包重跑复查。
|
||||
- 验证与防复发:回归覆盖生成到持久化的哈希绑定、跨语言规范化、逐节转换、事实状态改变后只拦受影响的节,以及验收包逐文件哈希。指定样稿待独立验收。
|
||||
- 关联记录:#220、BUG-1860、BUG-1862。诊断见 [执行进度](tasks/PROGRESS-reading-snapshot-binding-20261112.md)。
|
||||
- 修复版本:`codex/reading-snapshot-binding-20261112`,待发布。
|
||||
|
||||
## BUG-1864 | 个人报告未确认出生分钟时漏查分盘边界
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
|
||||
|
||||
## 2026-11-13 · #220 报告解读快照
|
||||
|
||||
- Windows 预检可见 Gitea 远端,碎片扫描与适配器步骤通过。既有 `test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets` 仍要求本机不存在的 `.workbuddy` 历史镜像路径。预检退出 1。
|
||||
- Windows 快速门用 `PYTHONUTF8=1`,避免默认 GBK 输出检查符号时中断。该套件会重写 `references/oracle/artifacts/pending_packets/` 的换行符。确认只有换行符差异后恢复运行生成的文件,提交时按路径暂存。
|
||||
|
||||
## 2026-11-13 · Windows 报告预检目录审计超时(#228)
|
||||
|
||||
- 在独立 worktree 上运行 `pre_work_check.py`,目录扫描及预检定向测试超时。延长外层超时后,目录扫描仍受 `preflight_fragment_scan.py` 调用 `audit_fragments.py --strict` 的 120 秒上限约束。Python 运行时、Gitea 远端可见性与外部引擎适配器诊断通过。记录见 [#228 进度](../tasks/PROGRESS-report-gender-20261113.md)。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# #220 需拍板答复:先交付生成时的快照、转换记录和离线工具;读取时重新复查拆到后续工单(2026-11-13,Claude)
|
||||
|
||||
本文件补充任务书。两者冲突时,以本文件为准。
|
||||
|
||||
## 结论
|
||||
|
||||
确认你的诊断:报告页和导出读的是已存的解读,读取时拿不到当前的事实包。读取时重新复查(任务书第 3 条)**拆到后续工单**,本单不做,也不得为此改动报告的生成流程。
|
||||
|
||||
本单交付以下内容:
|
||||
|
||||
1. 任务书第 1 条:存储结果里的 `snapshot`(各项哈希,以及 `rules`)。
|
||||
2. 任务书第 2 条:`transform_log`。每一节都要记下:是否通过、被拦的原因、各段的 `claims`、`refs`、`refs_from_text`、重试次数。不记模型原文。
|
||||
3. 任务书第 4、5 条:离线工具 `tools/report-reading-acceptance/`(在线模式和 `--replay` 模式),`manifest.json` 里的哈希必须互相对得上。在线模式要能直接读 `/workspace/keys/ds_key` 或环境变量,key 不得打印。工具里的样例盘用 Jobs 和 Obama 的完整坐标,并注明来源。
|
||||
4. 以后读取时要做复查,需要先能比对哈希,所以存储结果里必须保存 `facts_sha256`。后续工单的标题写进 PROGRESS:「报告读取时用当前事实包复查(需要先定事实包的来源)」。
|
||||
|
||||
## 验收
|
||||
|
||||
- 任务书验收第 1、3、4 条照旧。
|
||||
- 第 2 条改为单元测试:直接调用复查函数,输入同一份草稿和两份只差一个状态字段的事实包,两次的结果不同,并且 `transform_log` 记下了差异。这条测试证明可以复查,不要求页面在读取时复查。
|
||||
- 三份旧样稿的 `--replay` 由 Claude 在验收主机上跑。你在本机用合成的 raw 和 facts 测通离线模式即可。
|
||||
@@ -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,45 @@
|
||||
# #220 执行进度 · 报告解读生成快照
|
||||
|
||||
## 实施
|
||||
|
||||
- 按评论 42700 和 [修订任务书](FIX-reading-snapshot-binding-1.md) 接续认领。新解读保留 v4,存储增加 `snapshot` 和 `transform_log`。现有 v1–v4 仍可读取。
|
||||
- `input_sha256` 绑定实际传给报告引擎的请求体。`facts_sha256` 和 `caps_sha256` 绑定写作器使用的事实包及 `evidence_grades`。`raw_response_sha256` 对选中回复的 JSON 值计算。系统提示按 UTF-8 字节计算。`stored_sha256` 包含转换记录,计算时去掉 `snapshot`。规则版本从 1 开始,Git 提交优先采用部署环境的 `GITHUB_SHA`。
|
||||
- 每次尝试记下章节结果、拦截原因、原始与存储的段落绑定、自动推得的引用、字段重命名和丢弃情况。模型原文只在验收包中保留。生产读取 API 返回已通过的正文和被拦章节的原因,生成审计留在存储快照中。
|
||||
- 工具复用生产写作器和复查函数。旧草稿只转换 `claim_type` 与空绑定格式,复查按现行规则执行。复跑方式见 [验收工具](../../tools/report-reading-acceptance/README.md)。
|
||||
- 未修改既有断言。写作器测试补传必需的 `reportInput`,正常报告入口测试新增了请求与事实包的哈希断言。
|
||||
|
||||
## 验证
|
||||
|
||||
- 执行分支合入 `origin/staging` 的 #227 和 #228 后,报告、导出、档案入口定向测试 108 项通过,0 失败、0 取消。TypeScript 检查通过。ESLint 0 错误,62 条既有警告。
|
||||
- Next 生产构建完成,`/` 保持 `○ Static`。与同机 staging 构建对照,首页 39 个静态资源的 gzip-9 总量一致(JS 515,860 字节,CSS 90,906 字节)。
|
||||
- 合成草稿与事实包的离线回放核对九个文件哈希,并验证 `manifest.json` 与存储快照中的六个哈希一致。Jobs 和 Obama 两份公开盘均经本地引擎重算,再用合成草稿完成回放。
|
||||
- Python 报告定向 23 项、隐私守卫 62 项通过。快速门在交付分支与同机基线 `7670cf540` 均为 1,463 项通过、2 项跳过,`test_upstream_technique_layers.py` 的 3 项 PyJHora 检查同样失败。交付差异与当前 staging 的 `scripts/`、`tests/`、`references/` 一致。
|
||||
- Windows 前端全量以含 #227 的同机 staging `7670cf540` 对照。基线 3,688 项,3,649 通过、39 失败。分支 3,692 项,3,659 通过、33 失败。两边取消均为 0,基线测试名称完整保留,新增四项全部通过。分支额外失败的五项数据库测试串行重跑均通过。两边均失败的咨询限时测试在分支重跑时仍失败。合入 #228 后复跑相关定向测试及生产构建。
|
||||
- 预检的远端可见性、适配器诊断和碎片扫描通过。历史 `.workbuddy` 路径断言仍失败,预检退出 1,详见 [预检台账](../research/pre_work_error_ledger.md)。
|
||||
|
||||
## 后续工单
|
||||
|
||||
- 报告读取时用当前事实包复查(需要先定事实包的来源)。
|
||||
- Claude 在验收主机回放三份归档样稿,并验证 DeepSeek 在线模式。本机无受控凭据和归档样稿。
|
||||
|
||||
## 前一轮诊断
|
||||
|
||||
2026-11-13,Codex。核对基线为 staging `b8da77f43`,任务书提交为 `cd1794642`,采用工单评论 42657 的补充要求。
|
||||
|
||||
### 展示和导出读取链路
|
||||
|
||||
| 环节 | 核对结果 |
|
||||
| --- | --- |
|
||||
| 报告生成 | `personal-report-longform-generate.ts` 只在生成时向引擎请求 `include_reading_facts`。响应的 `readingFacts` 从计算结果中取出,传给 `writeReportReading`。 |
|
||||
| 持久化 | `personal-report-longform-snapshot.ts` 的严格 schema 保存报告 Markdown、表格、图表和解读。没有完整的 `reading_facts`,无法用已存投影重建当前事实包。 |
|
||||
| 报告读取 | `GET /api/reports/[reportId]` 校验报告归属,`loadLongformReading` 读取已存快照并返回 `snapshot.reading`。读路径没有重新计算事实的入口。 |
|
||||
| 导出 | 报告页把同一个 `reading` 交给 `ReportActions` 和 Markdown 视图。专业参考版接口也只读取已存附录 Markdown。 |
|
||||
|
||||
源码检查覆盖上述四处,核实没有可供展示或导出使用的当前事实包。现有引擎入口需要重新提交计算输入,给报告读取路径接入它会引入新的取数、计算和失败处理。按任务书的停工条件,本轮只记录诊断。快照哈希、转换记录及可见拦截原因、读时复查和验收包工具均待继续。指定的三份样稿本轮未回放。
|
||||
|
||||
`pytest tests/test_repo_privacy_markers.py -q` 的 62 项检查通过,`git diff --check` 通过。本轮仅改诊断文档,未运行应用构建或回归套件。
|
||||
|
||||
### 原待拍板事项
|
||||
|
||||
- 保留读时复查要求,由修订任务书授权服务端读取当前事实包。建议复用引擎计算,明确报告原始输入与当前档案的取值关系、计算失败的显示和导出行为,并约束事实包在服务端使用。
|
||||
- 若先交付生成时快照与离线验收工具,请明确拆单并把展示、导出读时复查列为后续验收项。事实包变化时,线上旧解读仍按目前的存储快照读取。
|
||||
@@ -31,6 +31,10 @@
|
||||
| 待核对 | 状态板建立时无法从 staging 历史确认,需要人工核对 |
|
||||
| 已上线 | 已随 `main` 提升进入生产 |
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [#220 修订任务书](FIX-reading-snapshot-binding-1.md) | [执行进度](PROGRESS-reading-snapshot-binding-20261112.md) | 个人报告生成快照、逐节拦截原因和离线验收包(BUG-1863) | 待验收 | `codex/reading-snapshot-binding-20261112` |
|
||||
|
||||
截至 2026-09-03,`main` 停在 2026-08-16 的 `7b620c7a`,下表**没有任何一项已上线**。
|
||||
|
||||
### 生时校正(rectification)
|
||||
|
||||
@@ -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`,路由和裁定里都没说清。
|
||||
- 要让某一行如实反映状态,必须新增计算。
|
||||
@@ -0,0 +1,63 @@
|
||||
# TASK:报告解读每次生成都绑定完整快照,事实变更后重新复查,提供可复跑的验收包工具(2026-11-12)
|
||||
|
||||
- Gitea:#220
|
||||
- 分支:`codex/reading-snapshot-binding-20261112`,基于 staging `b8da77f436a016a199a8fd6ffe2f91c70aa1ca53`。
|
||||
- 开工条件已满足:#219(BUG-1862)已合入 staging(b8da77f436a016a199a8fd6ffe2f91c70aa1ca53)。存储版本是 v4,逐段带 claims/grade/refs/refs_from_text。
|
||||
- Bug 编号:BUG-1863
|
||||
- 验收:Claude(jyotisha-e6)
|
||||
|
||||
不得自行写「产品决定/裁定」。任务书没写到的取舍,一律选「需拍板」。
|
||||
|
||||
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
|
||||
|
||||
占星师的引擎和资料(`scripts/jyotish_engine.py`、`scripts/yoga_engine.py`、`references/` 下她仓库已有的文件、`SKILL.md`)一律不改。产品差异只能放进单独的产品文件,并在 `references/product_runtime_contract.md` 公告(项目规则 2026-11-07)。
|
||||
|
||||
占星师回复原文:`docs/tasks/ref-astrologer-acceptance-reply-20261112.md`,由本分支第一个提交带入。下文的「她 §N」都指这份文件。
|
||||
|
||||
## 背景(她 §2 P0-3、§6)
|
||||
|
||||
上一版验收包有三个问题:
|
||||
|
||||
1. 模型稿生成之后,事实包又重建过(Badhaka 多了 `status` 字段),模型稿没有重跑。她认为状态变化会改变解释权限,不能据此宣称从 facts 到 caps、生成、复查、展示的整条链已经通过。
|
||||
2. A-2 的草稿和存储结果不相等,但没有字段级的转换记录。
|
||||
3. 公开样例的输入省略了坐标,无法独立重算。
|
||||
|
||||
## 要做的
|
||||
|
||||
1. **存储快照**(`writeReportReading` 写入的存储结果)新增 `snapshot`,内容如下:
|
||||
- 字段:`input_sha256`、`facts_sha256`、`caps_sha256`(`evidence_grades` 规范化 JSON)、`system_prompt_sha256`、`raw_response_sha256`、`stored_sha256`(不含 snapshot 自身)、`rules`。
|
||||
- `rules` 包括:`evidence_grade_contract`、复查规则版本号(新增常量,复查规则每次变化都要加一)、`git_commit`(取部署环境已有的提交号变量,取不到就填 `unknown`)。
|
||||
- 规范化 JSON:键排序、UTF-8、无多余空白。写一个共享函数,Python 和 TS 两边结果一致,并用测试证明。
|
||||
2. **转换记录**:存储结果新增 `transform_log`,逐节、逐段记录草稿到存储之间的字段级变化,包括:字段改名、丢弃的字段、被拦的节及原因、重试次数。不记录模型原文。
|
||||
3. **事实变更后重新复查**
|
||||
- 读取已存的解读并展示或导出时,如果当前事实包的 `facts_sha256` 与快照里的不同,就用当前事实包重新跑复查。
|
||||
- 不通过的节按现有方式隐藏,并在存储结果之外的展示层标记 `stale_facts_rechecked`。
|
||||
- 先找出展示时能否拿到当前事实包,写进 PROGRESS。拿不到就停下来,选需拍板,不得自行改报告生成流程。
|
||||
4. **验收包工具**:放在 `tools/report-reading-acceptance/` 下(脚本根目录不能新增文件,有卫生门禁)。
|
||||
- 输入:公开样例清单 `cases.json`,带完整的出生日期时间、时区、经纬度、地名、AA 评级、`report_mode`。Jobs 和 Obama 的坐标、时区写死并注明来源。
|
||||
- 两种模式:
|
||||
- 在线模式:用现有的 DeepSeek 调用,key 从环境变量读,不打印;
|
||||
- 离线模式 `--replay <raw.json>`:拿一份已有的原始输出,对**当前**事实包重新复查。
|
||||
- 每个样例输出一个目录:`input.json`、`facts.json`、`caps.json`、`system_prompt.txt`、`raw.json`、`reading.json`、`final.md`、`postcheck.json`、`transform_log.json`、`manifest.json`(以上全部文件的 sha256,加上 `rules`)。
|
||||
- 同一次运行里,`manifest.json` 中的 `facts_sha256` 必须等于 `reading.json` 快照里的值,否则以非零退出。
|
||||
- 结束时打印 git commit 和命令行,方便复跑。
|
||||
5. 工具的离线模式要能对上一版的三份 `draft-*.raw.json` 跑复查,产出结果写进 PROGRESS。三份文件在 `/workspace/eval-upstream-1107/report-192-acceptance-1112/报告解读层-验收材料-1112.zip`,不得提交进仓库。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- 复查规则本身,只加版本号常量
|
||||
- 事实包结构
|
||||
- 聊天
|
||||
- 冻结文件
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 规范化哈希:同一对象在 Python 和 TS 两边算出的 sha256 相同(测试里放三个样例对象)。
|
||||
2. 已存解读的事实包被改动一个状态字段后,展示时会重新复查。被影响的节隐藏,未受影响的节照常显示(她 §6 第 3 条「facts 状态变更后旧草稿」)。
|
||||
3. 离线模式在 CI 可以跑(用合成 raw 和 facts),不访问网络。
|
||||
4. 现有报告解读、MD 导出的测试全部通过,Python 快速质量门和 `npx eslint .` 为 0 个错误。
|
||||
|
||||
## 何时停下来选「需拍板」
|
||||
|
||||
- 展示时拿不到当前事实包。
|
||||
- DeepSeek 调用只能通过需要登录的服务端路由才能走,工具无法在本地复用。
|
||||
@@ -651,6 +651,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **Reading basis fallback (BUG-1860):** when 「依据与边界」 fails the post-check, the same section shows one explanation and a list with one calculation-backed line per claim. Each line names its ceiling, required evidence that is missing or near a boundary, and disclosures. The Markdown reading carries the same list. Other withheld sections keep their one-line placeholder.
|
||||
- **Reading evidence grades (BUG-1862):** a paragraph combining multiple claim types appends 「(综合 N 类结论)」 to its evidence grade. Fact paragraphs have no grade row. Markdown uses the same labels.
|
||||
- **Reading post-check reasons (BUG-1863):** when a section is withheld, the placeholder gives the known reason in plain Chinese. The reason sits above the calculation-backed list for 「依据与边界」. Markdown carries the same explanation.
|
||||
- **Technique audit status (BUG-1849):** newly generated English reports use readable calculation and interpretation phrases together in the Status cell, with the evidence qualifier when present. The reader also renders the earlier status format from saved reports.
|
||||
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. The selected segment keeps a neutral background; its text is `--color-action` and a 1px inset edge is `--color-action-strong`, matched on `[data-active]` or `[aria-selected="true"]`. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
|
||||
@@ -42,12 +42,12 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
|
||||
{view.status === "withheld" ? (
|
||||
view.basisAudit ? (
|
||||
<>
|
||||
<p className="personal-report-reading-withheld">{BASIS_AUDIT_INTRO}</p>
|
||||
<p className="personal-report-reading-withheld">{[view.explanation, BASIS_AUDIT_INTRO].filter(Boolean).join(" ")}</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{view.basisAudit.map((row) => <li key={row.claimType}>{basisAuditLine(row)}</li>)}
|
||||
</ul>
|
||||
</>
|
||||
) : <p className="personal-report-reading-withheld">{READING_SECTION_WITHHELD_TEXT}</p>
|
||||
) : <p className="personal-report-reading-withheld">{[READING_SECTION_WITHHELD_TEXT, view.explanation].filter(Boolean).join(" ")}</p>
|
||||
) : view.paragraphs.map((paragraph, paragraphIndex) => (
|
||||
<div className="personal-report-reading-paragraph" key={paragraphIndex}>
|
||||
<p>{paragraph.text}</p>
|
||||
|
||||
@@ -138,7 +138,6 @@ async function fetchLongformMarkdown(input: Readonly<{
|
||||
apiBase: string;
|
||||
fetchImpl: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
includeReadingFacts?: boolean;
|
||||
}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string; readingFacts?: unknown } & EnglishOutcome> {
|
||||
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
|
||||
const signal = input.signal
|
||||
@@ -150,10 +149,7 @@ async function fetchLongformMarkdown(input: Readonly<{
|
||||
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
...input.payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"],
|
||||
...(input.includeReadingFacts ? { include_reading_facts: true } : {}),
|
||||
}),
|
||||
body: JSON.stringify(input.payload),
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
@@ -172,7 +168,7 @@ async function fetchLongformMarkdown(input: Readonly<{
|
||||
return { markdown: result.markdown, factTables: assembleReportFactTables(result.fact_table_packet),
|
||||
charts: assembleLongformCharts(result.fact_table_packet, "ev-cover-longform"),
|
||||
readerDashaApplicability: typeof result.reader_dasha_applicability === "string" ? result.reader_dasha_applicability : "",
|
||||
...(input.includeReadingFacts ? { readingFacts: result.reading_facts } : {}),
|
||||
...(input.payload.include_reading_facts === true ? { readingFacts: result.reading_facts } : {}),
|
||||
...acceptEnglishEdition(result.markdown_en, result.english_unavailable, result.fact_table_packet) };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -234,18 +230,22 @@ export async function generatePersonalReportLongform(
|
||||
if (ensured.status === "ready") snapshot = ensured.snapshot;
|
||||
deps.signal?.throwIfAborted();
|
||||
if (!snapshot) try {
|
||||
const reportInput = {
|
||||
...payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"],
|
||||
...(deps.writeReading ? { include_reading_facts: true } : {}),
|
||||
};
|
||||
const generated = await fetchLongformMarkdown({
|
||||
payload,
|
||||
payload: reportInput,
|
||||
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
|
||||
fetchImpl: deps.fetchImpl ?? fetch,
|
||||
signal: deps.signal,
|
||||
includeReadingFacts: Boolean(deps.writeReading),
|
||||
});
|
||||
const { readingFacts, ...engine } = generated;
|
||||
// The reading is written before the snapshot, so the report reveals once with it (or without it).
|
||||
const reading = deps.writeReading
|
||||
? await writeReportReading({
|
||||
facts: readingFacts,
|
||||
reportInput,
|
||||
birthTimeConfirmed: readingMinuteConfirmed,
|
||||
write: deps.writeReading,
|
||||
signal: deps.signal,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const sha256 = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
const reason = z.string().regex(/^[a-z_]{1,64}$/);
|
||||
|
||||
export const readingRulesSchema = z.strictObject({
|
||||
evidence_grade_contract: z.string(),
|
||||
postcheck_version: z.number().int().positive(),
|
||||
git_commit: z.string().regex(/^(?:[0-9a-f]{40}|unknown)$/),
|
||||
});
|
||||
|
||||
export const readingSnapshotSchema = z.strictObject({
|
||||
input_sha256: sha256,
|
||||
facts_sha256: sha256,
|
||||
caps_sha256: sha256,
|
||||
system_prompt_sha256: sha256,
|
||||
raw_response_sha256: sha256,
|
||||
stored_sha256: sha256,
|
||||
rules: readingRulesSchema,
|
||||
});
|
||||
|
||||
const binding = z.strictObject({
|
||||
kind: z.literal("fact").optional(),
|
||||
claims: z.array(z.string()),
|
||||
grade: z.enum(["A", "B", "C", "D"]).optional(),
|
||||
refs: z.array(z.string()),
|
||||
refs_from_text: z.array(z.string()),
|
||||
});
|
||||
|
||||
const paragraphTransform = z.strictObject({
|
||||
paragraph: z.number().int().positive(),
|
||||
draft: binding,
|
||||
stored: binding.nullable(),
|
||||
refs_from_text: z.array(z.string()),
|
||||
trimmed_text: z.boolean(),
|
||||
renamed_fields: z.array(z.strictObject({ from: z.literal("claim_type"), to: z.literal("claims") })),
|
||||
discarded_fields: z.array(z.enum(["text", "kind", "claims", "claim_type", "grade", "refs", "refs_from_text"])),
|
||||
discarded_other_field_count: z.number().int().nonnegative(),
|
||||
unrecognized_ref_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const readingSectionTransformSchema = z.strictObject({
|
||||
id: z.string(),
|
||||
status: z.enum(["ok", "withheld"]),
|
||||
reason: reason.optional(),
|
||||
added_fields: z.array(z.enum(["title", "status", "reason", "basisAudit"])),
|
||||
paragraphs: z.array(paragraphTransform),
|
||||
});
|
||||
|
||||
export const readingAttemptSchema = z.strictObject({
|
||||
attempt: z.number().int().positive(),
|
||||
raw_response_sha256: sha256.optional(),
|
||||
status: z.enum(["accepted", "rejected", "failed"]),
|
||||
reason: reason.optional(),
|
||||
sections: z.array(readingSectionTransformSchema),
|
||||
});
|
||||
|
||||
export const readingTransformLogSchema = z.strictObject({
|
||||
selected_attempt: z.number().int().positive().nullable(),
|
||||
retries: z.number().int().nonnegative(),
|
||||
attempts: z.array(readingAttemptSchema),
|
||||
});
|
||||
|
||||
export type ReadingSectionTransform = z.infer<typeof readingSectionTransformSchema>;
|
||||
export type ReadingAttempt = z.infer<typeof readingAttemptSchema>;
|
||||
export type ReadingTransformLog = z.infer<typeof readingTransformLogSchema>;
|
||||
export type ReadingSnapshot = z.infer<typeof readingSnapshotSchema>;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { canonicalReportReadingSha256, reportReadingSha256 } from "./report-reading-canonical-json";
|
||||
import { EVIDENCE_GRADE_CONTRACT, EVIDENCE_GRADES, REPORT_CLAIM_TYPES } from "./personal-report-reading-contract";
|
||||
import type { BoundReadingSection, CheckedReportReading, ReportReadingDraftSection, SnapshottedReportReading } from "./personal-report-reading-contract";
|
||||
import type { ReadingSectionTransform, ReadingTransformLog } from "./personal-report-reading-audit-contract";
|
||||
|
||||
/** Bump when the report-reading post-check rules change. */
|
||||
export const REPORT_READING_POSTCHECK_VERSION = 1;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function knownRefs(value: unknown, catalog: ReadonlyMap<string, string>): string[] {
|
||||
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string" && catalog.has(id)) : [];
|
||||
}
|
||||
|
||||
function binding(value: Record<string, unknown>, catalog: ReadonlyMap<string, string>) {
|
||||
const claims = Array.isArray(value.claims) ? value.claims : [value.claim_type];
|
||||
return {
|
||||
...(value.kind === "fact" ? { kind: "fact" as const } : {}),
|
||||
claims: claims.filter((type) => REPORT_CLAIM_TYPES.some((allowed) => allowed === type)),
|
||||
grade: EVIDENCE_GRADES.find((grade) => grade === value.grade),
|
||||
refs: knownRefs(value.refs, catalog),
|
||||
refs_from_text: knownRefs(value.refs_from_text, catalog),
|
||||
};
|
||||
}
|
||||
|
||||
const draftFields = ["text", "kind", "claims", "claim_type", "grade", "refs", "refs_from_text"] as const;
|
||||
|
||||
export function describeReadingTransform(
|
||||
draft: ReportReadingDraftSection,
|
||||
section: BoundReadingSection,
|
||||
catalog: ReadonlyMap<string, string>,
|
||||
inferredRefs: readonly string[][],
|
||||
original?: unknown,
|
||||
): ReadingSectionTransform {
|
||||
const originalParagraphs = record(original).paragraphs;
|
||||
const addedFields: ReadingSectionTransform["added_fields"] = ["title", "status"];
|
||||
if (section.status === "withheld") {
|
||||
addedFields.push("reason");
|
||||
if (section.basisAudit) addedFields.push("basisAudit");
|
||||
}
|
||||
return {
|
||||
id: section.id,
|
||||
status: section.status,
|
||||
...(section.status === "withheld" ? { reason: section.reason } : {}),
|
||||
added_fields: addedFields,
|
||||
paragraphs: draft.paragraphs.map((checked, index) => {
|
||||
const raw = Array.isArray(originalParagraphs) ? record(originalParagraphs[index]) : checked;
|
||||
const stored = section.status === "ok" ? section.paragraphs[index] : undefined;
|
||||
const renamed = raw.claim_type != null && stored !== undefined && "claims" in stored && !Object.hasOwn(raw, "claims");
|
||||
const draftBinding = binding(raw, catalog);
|
||||
return {
|
||||
paragraph: index + 1,
|
||||
draft: draftBinding,
|
||||
stored: stored ? binding(stored, catalog) : null,
|
||||
refs_from_text: inferredRefs[index] ?? [],
|
||||
trimmed_text: stored !== undefined && raw.text !== stored.text,
|
||||
renamed_fields: renamed ? [{ from: "claim_type", to: "claims" }] : [],
|
||||
discarded_fields: draftFields.filter((key) => Object.hasOwn(raw, key)
|
||||
&& (!stored || !Object.hasOwn(stored, key)) && !(renamed && key === "claim_type")),
|
||||
discarded_other_field_count: Object.keys(raw).filter((key) => !draftFields.some((known) => key === known)).length,
|
||||
unrecognized_ref_count: (Array.isArray(raw.refs) ? raw.refs.length - draftBinding.refs.length : 0)
|
||||
+ (Array.isArray(raw.refs_from_text) ? raw.refs_from_text.length - draftBinding.refs_from_text.length : 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function reportReadingRules(commit = process.env.GITHUB_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.NEXT_PUBLIC_GIT_COMMIT) {
|
||||
return {
|
||||
evidence_grade_contract: EVIDENCE_GRADE_CONTRACT,
|
||||
postcheck_version: REPORT_READING_POSTCHECK_VERSION,
|
||||
git_commit: commit && /^[0-9a-f]{40}$/.test(commit) ? commit : "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export function bindReportReadingSnapshot(input: {
|
||||
reading: CheckedReportReading;
|
||||
reportInput: unknown;
|
||||
facts: Record<string, unknown>;
|
||||
system: string;
|
||||
rawSha256: string;
|
||||
transformLog: ReadingTransformLog;
|
||||
gitCommit?: string;
|
||||
}): SnapshottedReportReading {
|
||||
const stored = { ...input.reading, transform_log: input.transformLog };
|
||||
return {
|
||||
...stored,
|
||||
snapshot: {
|
||||
input_sha256: canonicalReportReadingSha256(input.reportInput),
|
||||
facts_sha256: canonicalReportReadingSha256(input.facts),
|
||||
caps_sha256: canonicalReportReadingSha256(input.facts.evidence_grades ?? null),
|
||||
system_prompt_sha256: reportReadingSha256(input.system),
|
||||
raw_response_sha256: input.rawSha256,
|
||||
stored_sha256: canonicalReportReadingSha256(stored),
|
||||
rules: reportReadingRules(input.gitCommit),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* report page. No prompt text here, so the page bundle stays small.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { readingSnapshotSchema, readingTransformLogSchema } from "./personal-report-reading-audit-contract";
|
||||
|
||||
export const REPORT_READING_SECTIONS = [
|
||||
{ id: "overview", title: "总评" },
|
||||
@@ -142,6 +143,16 @@ const loadedFile = z.strictObject({
|
||||
const storedSkill = z.strictObject({
|
||||
name: z.string().min(1).max(80), version: z.string().min(1).max(40), loaded: z.array(loadedFile).min(1).max(40),
|
||||
});
|
||||
const storedReadingV4 = z.strictObject({
|
||||
version: z.literal(4), language: z.literal("zh"), contract: z.literal(EVIDENCE_GRADE_CONTRACT),
|
||||
skill: storedSkill, sections: z.array(storedSectionV4).length(REPORT_READING_SECTIONS.length),
|
||||
});
|
||||
export type CheckedReportReading = z.infer<typeof storedReadingV4>;
|
||||
export type SnapshottedReportReading = z.infer<typeof storedReadingWithSnapshot>;
|
||||
const storedReadingWithSnapshot = storedReadingV4.extend({
|
||||
snapshot: readingSnapshotSchema,
|
||||
transform_log: readingTransformLogSchema,
|
||||
});
|
||||
/** 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({
|
||||
@@ -161,10 +172,8 @@ export const reportReadingSchema = z.union([
|
||||
skill: storedSkill,
|
||||
sections: z.array(storedSectionV3).length(REPORT_READING_SECTIONS.length),
|
||||
}),
|
||||
z.strictObject({
|
||||
version: z.literal(4), language: z.literal("zh"), contract: z.literal(EVIDENCE_GRADE_CONTRACT),
|
||||
skill: storedSkill, sections: z.array(storedSectionV4).length(REPORT_READING_SECTIONS.length),
|
||||
}),
|
||||
storedReadingV4,
|
||||
storedReadingWithSnapshot,
|
||||
]);
|
||||
export type ReportReading = z.infer<typeof reportReadingSchema>;
|
||||
export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGrade; claimCount?: number }>;
|
||||
@@ -172,6 +181,32 @@ export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGr
|
||||
/** Shown in place of a section that did not pass the post-check (owner 2026-11-12). */
|
||||
export const READING_SECTION_WITHHELD_TEXT = "这一节没有通过数据核对,暂不显示。";
|
||||
export const BASIS_AUDIT_INTRO = "这一节的模型文字没有通过核对,下面是由计算直接生成的依据清单。";
|
||||
const WITHHELD_REASONS: Readonly<Record<string, string>> = {
|
||||
field_name: "文字里混入了内部标记。",
|
||||
banned_phrase: "措辞超出了这份报告能判断的范围。",
|
||||
too_long: "有段文字超过了篇幅限制。",
|
||||
too_short: "有段文字没有完整写出。",
|
||||
disclaimer: "同一节重复写了边界说明。",
|
||||
claim_binding_missing: "有段判断没有标清使用了哪些依据。",
|
||||
claim_type_section: "这节用了与主题不对应的判断依据。",
|
||||
grade_above_cap: "判断的证据等级高于现有依据能支持的等级。",
|
||||
false_no_blocker: "有一项必需依据还不能用,文字里没有说明。",
|
||||
dignity_mismatch: "星曜状态与计算结果对不上。",
|
||||
false_missing_layer: "文字说有一项没有计算,但计算结果里有这一项。",
|
||||
current_advice_in_replay: "历史回看里写入了面向当下的建议。",
|
||||
double_transit_scope: "双行运没有核对到这节涉及的宫位。",
|
||||
yoga_status_mismatch: "格局状态与计算结果对不上。",
|
||||
house_lord_mismatch: "宫主与计算结果对不上。",
|
||||
sav_total_as_strength: "文字用固定的八分法总分判断了个人强弱。",
|
||||
badhaka_role_mix: "文字混淆了阻碍宫和阻碍宫主。",
|
||||
health_outcome: "文字对具体健康问题作了判断。",
|
||||
accuracy_claim: "文字给出了没有依据的准确率。",
|
||||
ref_outside_claims: "引用的依据与这段判断对不上。",
|
||||
ref_unstable: "判断用到的依据还不能支持这个证据等级。",
|
||||
timing_unbound: "时间判断没有对应的时间依据。",
|
||||
fact_with_inference: "盘面事实里混入了未经分级的判断。",
|
||||
claim_not_established: "摘要或建议引用了正文还没有核对通过的判断。",
|
||||
};
|
||||
const CLAIM_LABELS: Readonly<Record<ReportClaimType, string>> = {
|
||||
character_structure: "性格结构",
|
||||
career_promise: "事业倾向",
|
||||
@@ -222,12 +257,14 @@ export const EVIDENCE_GRADE_MEANING: Readonly<Record<EvidenceGrade, string>> = {
|
||||
/** One reading section as the page and the export show it, for every stored version. */
|
||||
export function readingSectionView(section: ReportReading["sections"][number]):
|
||||
| Readonly<{ status: "ok"; paragraphs: readonly ReportReadingParagraph[] }>
|
||||
| Readonly<{ status: "withheld"; basisAudit?: readonly BasisAuditRow[] }> {
|
||||
| Readonly<{ status: "withheld"; explanation?: string; basisAudit?: readonly BasisAuditRow[] }> {
|
||||
if (!("status" in section)) return { status: "ok", paragraphs: section.paragraphs.map((text) => ({ text })) };
|
||||
if (section.status === "withheld") {
|
||||
return section.id === "basis" && section.basisAudit
|
||||
? { status: "withheld", basisAudit: section.basisAudit }
|
||||
: { status: "withheld" };
|
||||
return {
|
||||
status: "withheld",
|
||||
explanation: Object.hasOwn(WITHHELD_REASONS, section.reason) ? WITHHELD_REASONS[section.reason] : undefined,
|
||||
...(section.id === "basis" && section.basisAudit ? { basisAudit: section.basisAudit } : {}),
|
||||
};
|
||||
}
|
||||
return { status: "ok", paragraphs: section.paragraphs.map((paragraph) => {
|
||||
const { text } = paragraph;
|
||||
|
||||
@@ -22,9 +22,14 @@ import {
|
||||
type BoundReadingParagraph,
|
||||
type BoundReadingSection,
|
||||
type EvidenceGrade,
|
||||
type ReportReading,
|
||||
type CheckedReportReading,
|
||||
type ReportReadingDraftSection,
|
||||
type SnapshottedReportReading,
|
||||
} from "./personal-report-reading-contract";
|
||||
import { bindReportReadingSnapshot, describeReadingTransform } from "./personal-report-reading-audit";
|
||||
import type { ReadingAttempt, ReadingSectionTransform, ReadingTransformLog } from "./personal-report-reading-audit-contract";
|
||||
import { canonicalReportReadingSha256 } from "./report-reading-canonical-json";
|
||||
import type { ReportReading } from "./personal-report-reading-contract";
|
||||
|
||||
export {
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
@@ -407,7 +412,7 @@ function refsFromText(text: string, allowed: ReadonlySet<string>, catalog: Catal
|
||||
return refs;
|
||||
}
|
||||
|
||||
type Checked = { section: BoundReadingSection; chars: number };
|
||||
type Checked = { section: BoundReadingSection; chars: number; inferredRefs: string[][] };
|
||||
|
||||
function checkSection(
|
||||
section: ReportReadingDraftSection,
|
||||
@@ -419,9 +424,11 @@ function checkSection(
|
||||
catalog: Catalog,
|
||||
established: ReadonlyMap<string, EvidenceGrade>,
|
||||
): Checked {
|
||||
const inferredRefs: string[][] = [];
|
||||
const withheld = (reason: ReadingRejection): Checked => ({
|
||||
section: { id: section.id, title, status: "withheld" as const, reason, ...(section.id === "basis" ? { basisAudit: basisAudit(claims) } : {}) },
|
||||
chars: 0,
|
||||
inferredRefs,
|
||||
});
|
||||
const graded = GRADED_SECTION_IDS.includes(section.id);
|
||||
const summary = section.id === "overview" || section.id === "advice";
|
||||
@@ -471,6 +478,7 @@ function checkSection(
|
||||
const inferred = refsFromText(text, allowed, catalog);
|
||||
if (!inferred) return withheld("ref_outside_claims");
|
||||
const addedRefs = [...inferred].filter((id) => !refs.has(id));
|
||||
inferredRefs[paragraphs.length] = addedRefs;
|
||||
for (const id of addedRefs) refs.add(id);
|
||||
if (paragraph.grade !== "D" && [...refs].some((id) => UNSTABLE_REFERENCE.has(catalog.states.get(id)!))) return withheld("ref_unstable");
|
||||
if (NO_BLOCKER.test(text) && (graded ? bindings.some((claim) => claim.cap === "D") : anyClaimMissing)) {
|
||||
@@ -482,6 +490,7 @@ function checkSection(
|
||||
return {
|
||||
section: { id: section.id, title, status: "ok", paragraphs },
|
||||
chars,
|
||||
inferredRefs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -497,14 +506,19 @@ export type ReadingSkillMeta = Readonly<{
|
||||
* 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 } {
|
||||
export function acceptReportReadingDraft(
|
||||
value: unknown,
|
||||
facts: Record<string, unknown> | null,
|
||||
skill: ReadingSkillMeta,
|
||||
originalSections?: readonly unknown[],
|
||||
):
|
||||
| { ok: true; reading: CheckedReportReading; withheld: number; sections: ReadingSectionTransform[] }
|
||||
| { ok: false; reason: ReadingRejection; sections: ReadingSectionTransform[] } {
|
||||
const parsed = reportReadingDraftEnvelopeSchema.safeParse(value);
|
||||
if (!parsed.success) return { ok: false, reason: "shape" };
|
||||
if (!parsed.success) return { ok: false, reason: "shape", sections: [] };
|
||||
const sections = parsed.data.sections;
|
||||
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
|
||||
return { ok: false, reason: "shape" };
|
||||
return { ok: false, reason: "shape", sections: [] };
|
||||
}
|
||||
const claims = claimsOf(facts);
|
||||
const dignities = dignitiesOf(facts);
|
||||
@@ -525,14 +539,17 @@ export function acceptReportReadingDraft(value: unknown, facts: Record<string, u
|
||||
}
|
||||
}
|
||||
const finalized = checked.map((entry, index) => entry ?? check(sections[index]!, index));
|
||||
const transforms = finalized.map(({ section, inferredRefs }, index) =>
|
||||
describeReadingTransform(sections[index]!, section, catalog.states, inferredRefs, originalSections?.[index]));
|
||||
const withheld = finalized.filter(({ section }) => section.status === "withheld").length;
|
||||
if (withheld === finalized.length) return { ok: false, reason: "all_sections_withheld" };
|
||||
if (withheld === finalized.length) return { ok: false, reason: "all_sections_withheld", sections: transforms };
|
||||
const total = finalized.reduce((sum, { chars }) => sum + chars, 0);
|
||||
if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short" };
|
||||
if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long" };
|
||||
if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short", sections: transforms };
|
||||
if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long", sections: transforms };
|
||||
return {
|
||||
ok: true,
|
||||
withheld,
|
||||
sections: transforms,
|
||||
reading: {
|
||||
version: 4,
|
||||
language: "zh",
|
||||
@@ -574,19 +591,33 @@ export type ReportReadingWriter = ((input: Readonly<{
|
||||
system: string;
|
||||
user: string;
|
||||
signal: AbortSignal;
|
||||
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>) & Readonly<{
|
||||
}>) => Promise<{
|
||||
body: unknown;
|
||||
rawResponse?: unknown;
|
||||
originalSections?: readonly 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;
|
||||
reading: SnapshottedReportReading | null;
|
||||
/** Why there is no reading; absent when there is one. */
|
||||
unavailable?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
transformLog?: ReadingTransformLog;
|
||||
}>;
|
||||
|
||||
/** Reader and Markdown export need the accepted sections, not the generation audit. */
|
||||
export function reportReadingForReader(reading: ReportReading): ReportReading {
|
||||
if (!("snapshot" in reading)) return reading;
|
||||
const { version, language, contract, skill, sections } = reading;
|
||||
return { version, language, contract, skill, sections };
|
||||
}
|
||||
|
||||
function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
const timer = globalThis.setTimeout(() => controller.abort(new Error("reading_timeout")), timeoutMs);
|
||||
@@ -619,7 +650,9 @@ function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
|
||||
export async function writeReportReading(input: Readonly<{
|
||||
facts: unknown;
|
||||
birthTimeConfirmed: boolean;
|
||||
reportInput: Record<string, unknown>;
|
||||
write: ReportReadingWriter;
|
||||
gitCommit?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
attempts?: number;
|
||||
@@ -631,7 +664,8 @@ export async function writeReportReading(input: Readonly<{
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let unavailable = "model_failed";
|
||||
let best: { reading: ReportReading; withheld: number } | null = null;
|
||||
const attempts: ReadingAttempt[] = [];
|
||||
let best: { reading: CheckedReportReading; withheld: number; rawSha256: string; attempt: 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);
|
||||
@@ -642,21 +676,30 @@ export async function writeReportReading(input: Readonly<{
|
||||
]);
|
||||
inputTokens += Math.max(0, result.inputTokens);
|
||||
outputTokens += Math.max(0, result.outputTokens);
|
||||
const accepted = acceptReportReadingDraft(result.body, facts, skill);
|
||||
const rawSha256 = canonicalReportReadingSha256(result.rawResponse ?? result.body ?? null);
|
||||
const accepted = acceptReportReadingDraft(result.body, facts, skill, result.originalSections);
|
||||
if (accepted.ok) {
|
||||
if (!best || accepted.withheld < best.withheld) best = { reading: accepted.reading, withheld: accepted.withheld };
|
||||
attempts.push({ attempt: attempt + 1, raw_response_sha256: rawSha256, status: "accepted", sections: accepted.sections });
|
||||
if (!best || accepted.withheld < best.withheld) {
|
||||
best = { reading: accepted.reading, withheld: accepted.withheld, rawSha256, attempt: attempt + 1 };
|
||||
}
|
||||
if (accepted.withheld === 0) break;
|
||||
} else {
|
||||
unavailable = `rejected_${accepted.reason}`;
|
||||
attempts.push({ attempt: attempt + 1, raw_response_sha256: rawSha256, status: "rejected", reason: accepted.reason, sections: accepted.sections });
|
||||
}
|
||||
} catch {
|
||||
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
|
||||
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
|
||||
attempts.push({ attempt: attempt + 1, status: "failed", reason: unavailable, sections: [] });
|
||||
} finally {
|
||||
timed.dispose();
|
||||
}
|
||||
}
|
||||
return best
|
||||
? { reading: best.reading, inputTokens, outputTokens }
|
||||
: { reading: null, unavailable, inputTokens, outputTokens };
|
||||
const transformLog = { selected_attempt: best?.attempt ?? null, retries: Math.max(0, attempts.length - 1), attempts };
|
||||
const reading = best ? bindReportReadingSnapshot({
|
||||
reading: best.reading, rawSha256: best.rawSha256, transformLog,
|
||||
reportInput: input.reportInput, facts, system, gitCommit: input.gitCommit,
|
||||
}) : null;
|
||||
return { reading, ...(reading ? {} : { unavailable }), inputTokens, outputTokens, transformLog };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReportFactTable } from "./report-fact-table-schema";
|
||||
import type { EnglishEditionStatus } from "./report-language";
|
||||
import type { ReportReading } from "./personal-report-reading-contract";
|
||||
import { reportReadingForReader } from "./personal-report-reading";
|
||||
import { PRODUCT_DEFAULT_NODE_MODE, PRODUCT_DEFAULT_POSITION_MODE } from "./calculation-modes.ts";
|
||||
import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range";
|
||||
/**
|
||||
@@ -781,7 +782,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
status: 200,
|
||||
body: {
|
||||
report: reportView(row, job),
|
||||
...(reading ? { reading } : {}),
|
||||
...(reading ? { reading: reportReadingForReader(reading) } : {}),
|
||||
englishEdition: english.status,
|
||||
...(english.status === "ready" ? {
|
||||
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
|
||||
|
||||
@@ -95,11 +95,11 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
|
||||
const view = readingSectionView(section);
|
||||
if (view.status === "withheld" && view.basisAudit) {
|
||||
const lines = view.basisAudit.map((row) => `- ${basisAuditLine(row).replace(/[\\`*_[\]<>&]/g, "\\$&").replace(/[\r\n]+/g, " ")}`);
|
||||
return [`### ${section.title}`, BASIS_AUDIT_INTRO, lines.join("\n")].filter(Boolean);
|
||||
return [`### ${section.title}`, [view.explanation, BASIS_AUDIT_INTRO].filter(Boolean).join(" "), lines.join("\n")].filter(Boolean);
|
||||
}
|
||||
// The export carries the same grade tags and fallback as the page.
|
||||
return [`### ${section.title}`, ...(view.status === "withheld"
|
||||
? [READING_SECTION_WITHHELD_TEXT]
|
||||
? [[READING_SECTION_WITHHELD_TEXT, view.explanation].filter(Boolean).join(" ")]
|
||||
: view.paragraphs.map(paragraph => paragraph.grade ? `${paragraph.text}\n\n${evidenceGradeTag(paragraph.grade, paragraph.claimCount)}` : paragraph.text))];
|
||||
}),
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** UTF-16 key order and ECMAScript JSON number/string encoding, shared with the Python verifier. */
|
||||
export function canonicalReportReadingJson(value: unknown): string {
|
||||
const json = JSON.stringify(value);
|
||||
if (json === undefined) throw new TypeError("Expected a JSON value");
|
||||
|
||||
function encode(item: unknown): string {
|
||||
if (Array.isArray(item)) return `[${item.map(encode).join(",")}]`;
|
||||
if (item !== null && typeof item === "object") {
|
||||
const object = item as Record<string, unknown>;
|
||||
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${encode(object[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
|
||||
return encode(JSON.parse(json));
|
||||
}
|
||||
|
||||
export function reportReadingSha256(value: string | Uint8Array): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function canonicalReportReadingSha256(value: unknown): string {
|
||||
return reportReadingSha256(canonicalReportReadingJson(value));
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
|
||||
const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
|
||||
return {
|
||||
body: (result as { object?: unknown }).object,
|
||||
...(result.text?.trim() ? { rawResponse: result.text } : {}),
|
||||
inputTokens: Math.max(0, usage?.inputTokens ?? 0),
|
||||
outputTokens: Math.max(0, usage?.outputTokens ?? 0),
|
||||
};
|
||||
|
||||
@@ -252,7 +252,7 @@ function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
|
||||
// #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) });
|
||||
const outcome = await writeReportReading({ reportInput: {}, facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
|
||||
assert.ok(outcome.reading && outcome.reading.version === 4);
|
||||
assert.ok(outcome.reading.sections.every((section) => "status" in section && section.status === "ok"));
|
||||
assert.deepEqual(outcome.reading.skill, SKILL);
|
||||
@@ -265,30 +265,30 @@ test("one retry after a draft with a withheld section; tokens of both attempts a
|
||||
|
||||
test("no facts, a failing model or a hung model all leave the report without a reading", async () => {
|
||||
const calls: string[] = [];
|
||||
const noFacts = await writeReportReading({ facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
|
||||
const noFacts = await writeReportReading({ reportInput: {}, facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
|
||||
assert.deepEqual(noFacts, { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 });
|
||||
const oldFacts = { ...FACTS, evidence_grades: { ...FACTS.evidence_grades, dependency_catalog: undefined } };
|
||||
const noCatalog = await writeReportReading({ facts: oldFacts, birthTimeConfirmed: true, write: writer([draft()], calls) });
|
||||
const noCatalog = await writeReportReading({ reportInput: {}, facts: oldFacts, birthTimeConfirmed: true, write: writer([draft()], calls) });
|
||||
assert.deepEqual(noCatalog, noFacts);
|
||||
assert.equal(calls.length, 0);
|
||||
const failed = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
|
||||
const failed = await writeReportReading({ reportInput: {}, facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
|
||||
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: Object.assign(() => new Promise<never>(() => undefined), { prompt: PROMPT }) });
|
||||
const hung = await writeReportReading({ reportInput: {}, 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");
|
||||
// #192 原值:两稿都含禁用词即整份不可用(rejected_banned_phrase);新值:只有那一节不显示,结构错误才整份不可用
|
||||
const partial = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
|
||||
const partial = await writeReportReading({ reportInput: {}, 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: [] }]) });
|
||||
const rejected = await writeReportReading({ reportInput: {}, 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,
|
||||
reportInput: {}, facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
|
||||
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"));
|
||||
@@ -434,6 +434,7 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
|
||||
mock.module("server-only", { defaultExport: {} });
|
||||
const { generatePersonalReportLongform } = await import("./src/lib/personal-report-longform-generate.ts");
|
||||
const { readLongformSnapshot } = await import("./src/lib/personal-report-longform-snapshot.ts");
|
||||
const { canonicalReportReadingSha256 } = await import("./src/lib/report-reading-canonical-json.ts");
|
||||
const packet = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-engine.json", "utf8"));
|
||||
const golden = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-reader.json", "utf8"));
|
||||
const mode = ${JSON.stringify(mode)};
|
||||
@@ -470,6 +471,8 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
|
||||
if (mode === "reading") {
|
||||
assert.equal(stored.snapshot.reading.sections.length, 9);
|
||||
assert.equal(stored.snapshot.reading.sections[0].title, "总评");
|
||||
assert.equal(stored.snapshot.reading.snapshot.input_sha256, canonicalReportReadingSha256(bodies[0]));
|
||||
assert.equal(stored.snapshot.reading.snapshot.facts_sha256, canonicalReportReadingSha256(facts));
|
||||
assert.deepEqual(result.usage, { inputTokens: 1200, outputTokens: 800 });
|
||||
} else if (mode === "model-down") {
|
||||
assert.equal(stored.snapshot.reading, undefined);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ReportReadingSection } from "../src/components/personal-report/report-reading-section";
|
||||
import { acceptReportReadingDraft, reportReadingForReader, writeReportReading } from "../src/lib/personal-report-reading";
|
||||
import { EVIDENCE_GRADE_CONTRACT, REPORT_READING_SECTIONS, reportReadingSchema } from "../src/lib/personal-report-reading-contract";
|
||||
import { canonicalReportReadingJson, canonicalReportReadingSha256, reportReadingSha256 } from "../src/lib/report-reading-canonical-json";
|
||||
import { buildReportExportBlocks } from "../src/lib/report-export-blocks";
|
||||
|
||||
const frontend = fileURLToPath(new URL("../", import.meta.url));
|
||||
const tool = fileURLToPath(new URL("../../tools/report-reading-acceptance/", import.meta.url));
|
||||
const SKILL = { name: "reading", version: "test", loaded: [{ path: "SKILL.md", sha256: "0".repeat(64) }] };
|
||||
const INPUT = { format: "markdown", include_reading_facts: true };
|
||||
const COMMIT = "a".repeat(40);
|
||||
const NEUTRAL = "这段只复述虚构记录中的顺序、参与者位置和需要核对的线索。读者可对照各行说明追溯记录。".repeat(4);
|
||||
const RAW_TEXT = "D10 列在事业节的资料里。";
|
||||
|
||||
function facts(state = "executed") {
|
||||
return {
|
||||
schema: "report-reading-facts-v3", planets: {}, domains: {}, timing: {}, functional: {},
|
||||
evidence_grades: {
|
||||
contract: EVIDENCE_GRADE_CONTRACT,
|
||||
claims: { career_promise: { cap: "C", sections: ["career"], required: [{ id: "v:D10" }] } },
|
||||
dependency_catalog: { "v:D10": { state, aliases: ["D10"] } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function draft(legacy = false) {
|
||||
return { sections: REPORT_READING_SECTIONS.map(({ id }) => ({
|
||||
id,
|
||||
paragraphs: [
|
||||
...Array.from({ length: 2 }, () => legacy ? { text: NEUTRAL, claim_type: null, grade: null } : { text: NEUTRAL, kind: "fact" }),
|
||||
...(id === "career" ? [legacy ? { text: ` ${RAW_TEXT} `, claim_type: "career_promise", grade: "C" }
|
||||
: { text: ` ${RAW_TEXT} `, claims: ["career_promise", "career_promise"], grade: "C" }] : []),
|
||||
],
|
||||
})) };
|
||||
}
|
||||
|
||||
test("report snapshot canonical JSON and SHA-256 agree with Python for three payloads", () => {
|
||||
const samples = [
|
||||
{ "10": "十", "2": "二", nested: { z: false, a: null }, array: [3, 2, 1] },
|
||||
{ "\uE000": "BMP", "😀": "supplementary", string: "行\n\t\"\\\u2028\ud800" },
|
||||
{ numbers: [-0, 21.3, -157.86666666666667, 1e-7, 1e-6, 1e20, 1e21, 5e-324, 333333333.33333329] },
|
||||
];
|
||||
const python = spawnSync(process.env.PYTHON ?? (process.platform === "win32" ? "python" : "python3"), [join(tool, "canonical_json.py")], {
|
||||
input: JSON.stringify(samples), encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
assert.equal(python.status, 0, python.error?.message ?? python.stderr);
|
||||
assert.deepEqual(python.stdout.trim().split(/\r?\n/), samples.map(canonicalReportReadingSha256));
|
||||
assert.match(canonicalReportReadingJson(samples[0]), /^\{"10":"十","2":"二"/);
|
||||
});
|
||||
|
||||
test("the same raw draft is rechecked against one changed fact state and logs the affected section", () => {
|
||||
const raw = draft();
|
||||
const stable = acceptReportReadingDraft(raw, facts(), SKILL);
|
||||
const sensitive = acceptReportReadingDraft(raw, facts("boundary_sensitive"), SKILL);
|
||||
assert.ok(stable.ok && sensitive.ok);
|
||||
const stableCareer = stable.sections.find(({ id }) => id === "career")!;
|
||||
const sensitiveCareer = sensitive.sections.find(({ id }) => id === "career")!;
|
||||
assert.equal(stableCareer.status, "ok");
|
||||
assert.deepEqual(stableCareer.paragraphs.at(-1)?.stored?.refs_from_text, ["v:D10"]);
|
||||
assert.equal(stableCareer.paragraphs.at(-1)?.trimmed_text, true);
|
||||
assert.deepEqual(stableCareer.paragraphs.at(-1)?.draft.claims, ["career_promise", "career_promise"]);
|
||||
assert.deepEqual(stableCareer.paragraphs.at(-1)?.stored?.claims, ["career_promise"]);
|
||||
assert.equal(sensitiveCareer.reason, "ref_unstable");
|
||||
assert.deepEqual(sensitiveCareer.paragraphs.at(-1)?.refs_from_text, ["v:D10"]);
|
||||
assert.equal(sensitiveCareer.paragraphs.at(-1)?.stored, null);
|
||||
assert.ok(sensitiveCareer.paragraphs.at(-1)?.discarded_fields.includes("text"));
|
||||
assert.deepEqual(stable.sections.filter(({ id }) => id !== "career"), sensitive.sections.filter(({ id }) => id !== "career"));
|
||||
assert.ok(!JSON.stringify(sensitive.sections).includes(RAW_TEXT));
|
||||
const html = renderToStaticMarkup(createElement(ReportReadingSection, { reading: sensitive.reading }));
|
||||
const markdown = buildReportExportBlocks({ markdown: "", reading: sensitive.reading }).find(({ id }) => id === "body:reading")!.text;
|
||||
for (const output of [html, markdown]) {
|
||||
assert.match(output, /判断用到的依据还不能支持这个证据等级/);
|
||||
assert.doesNotMatch(output, /ref_unstable|D10 列在事业节/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the stored snapshot binds the selected raw response and records a failed retry", async () => {
|
||||
const raw = draft();
|
||||
const rawResponse = JSON.stringify(raw, null, 2);
|
||||
const evidence = facts("boundary_sensitive");
|
||||
let calls = 0;
|
||||
const result = await writeReportReading({
|
||||
reportInput: INPUT, birthTimeConfirmed: true, facts: evidence, gitCommit: COMMIT,
|
||||
write: Object.assign(async () => {
|
||||
if (calls++ > 0) throw new Error("upstream failed");
|
||||
return { body: raw, rawResponse, inputTokens: 10, outputTokens: 20 };
|
||||
}, { prompt: { system: "原始提示", skill: SKILL } }),
|
||||
});
|
||||
assert.ok(result.reading);
|
||||
const { snapshot, ...stored } = result.reading;
|
||||
assert.deepEqual(snapshot, {
|
||||
input_sha256: canonicalReportReadingSha256(INPUT),
|
||||
facts_sha256: canonicalReportReadingSha256(evidence),
|
||||
caps_sha256: canonicalReportReadingSha256(evidence.evidence_grades),
|
||||
system_prompt_sha256: reportReadingSha256("原始提示"),
|
||||
raw_response_sha256: canonicalReportReadingSha256(rawResponse),
|
||||
stored_sha256: canonicalReportReadingSha256(stored),
|
||||
rules: { evidence_grade_contract: EVIDENCE_GRADE_CONTRACT, postcheck_version: 1, git_commit: COMMIT },
|
||||
});
|
||||
assert.equal(stored.transform_log.selected_attempt, 1);
|
||||
assert.equal(stored.transform_log.retries, 1);
|
||||
assert.equal(stored.transform_log.attempts[0].sections.length, 9);
|
||||
assert.deepEqual(stored.transform_log.attempts[1], { attempt: 2, status: "failed", reason: "model_failed", sections: [] });
|
||||
assert.ok(!JSON.stringify(stored.transform_log).includes(RAW_TEXT));
|
||||
assert.ok(reportReadingSchema.safeParse(JSON.parse(JSON.stringify(result.reading))).success);
|
||||
assert.equal(reportReadingSchema.safeParse({ ...stored, transform_log: undefined }).success, false);
|
||||
const publicReading = reportReadingForReader(result.reading);
|
||||
assert.equal("snapshot" in publicReading, false);
|
||||
assert.equal("transform_log" in publicReading, false);
|
||||
assert.deepEqual(publicReading.sections, result.reading.sections);
|
||||
});
|
||||
|
||||
test("offline acceptance replays an archived draft and the manifest matches every artifact", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "reading-acceptance-test-"));
|
||||
try {
|
||||
const rawPath = join(dir, "fixture-raw.json");
|
||||
const factsPath = join(dir, "fixture-facts.json");
|
||||
writeFileSync(rawPath, JSON.stringify(draft(true)));
|
||||
writeFileSync(factsPath, JSON.stringify(facts()));
|
||||
const run = spawnSync(process.execPath, ["--import", "tsx", join(tool, "run.mts"), "--case", "jobs",
|
||||
"--out", dir, "--replay", rawPath, "--facts", factsPath, "--python", "not-a-python-executable"], {
|
||||
cwd: frontend, encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
assert.equal(run.status, 0, run.error?.message ?? run.stderr);
|
||||
const file = (name: string) => readFileSync(join(dir, "jobs", name));
|
||||
const manifest = JSON.parse(file("manifest.json").toString("utf8"));
|
||||
const { snapshot, ...stored } = JSON.parse(file("reading.json").toString("utf8"));
|
||||
assert.equal(manifest.mode, "replay");
|
||||
assert.equal(manifest.facts_sha256, snapshot.facts_sha256);
|
||||
assert.equal(manifest.stored_sha256, canonicalReportReadingSha256(stored));
|
||||
assert.equal(Object.keys(manifest.files_sha256).length, 9);
|
||||
for (const [name, hash] of Object.entries(manifest.files_sha256)) assert.equal(reportReadingSha256(file(name)), hash, name);
|
||||
const log = JSON.parse(file("transform_log.json").toString("utf8"));
|
||||
assert.deepEqual(log, stored.transform_log);
|
||||
assert.deepEqual(log.attempts[0].sections.find(({ id }: { id: string }) => id === "career").paragraphs.at(-1).renamed_fields,
|
||||
[{ from: "claim_type", to: "claims" }]);
|
||||
assert.deepEqual(JSON.parse(file("raw.json").toString("utf8")), draft(true));
|
||||
assert.match(file("final.md").toString("utf8"), /证据等级 C/);
|
||||
assert.match(run.stdout, /^git [a-f0-9]{40}/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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")),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
# 报告解读验收包
|
||||
|
||||
在仓库的 `frontend` 目录运行。先安装该目录的 npm 依赖。现场重算事实包还需要安装仓库的 Python 运行依赖。
|
||||
|
||||
## 离线回放
|
||||
|
||||
```sh
|
||||
node --import tsx ../tools/report-reading-acceptance/run.mts \
|
||||
--case jobs --out /tmp/report-220-a1 --replay /path/to/draft-A-1.raw.json
|
||||
```
|
||||
|
||||
工具默认调用本仓报告计算服务重算当前事实包,不启动 HTTP 服务。`--python` 可以选择 Python 解释器,例如 Windows 下使用 `--python python`。也可以用 `--facts /path/to/current-facts.json` 传入当前事实包。CI 用合成草稿和事实包走这条路径,无需网络或模型凭据。
|
||||
|
||||
三份归档草稿分别指定独立输出目录。A-1、A-2 对应 `jobs`,B-1 对应 `obama`。旧草稿的 `claim_type` 转成 `claims` 数组,双空的 `claim_type` 和 `grade` 转成事实段。现行复查仍会检查每一段,转换记录保留字段变化。样例的 `today` 固定为原验收日期。出生时间与 Rodden 评级来自 `cases.json` 所列 Astro-Databank,十进制市级坐标沿用仓库的 [Jobs 公开记录](../../references/real_case_calibration/minute_rectification_development_v1.json) 和 [Obama 公开记录](../../references/real_case_calibration/minute_rectification_holdout_v4.json)。
|
||||
|
||||
## 在线生成
|
||||
|
||||
```sh
|
||||
node --import tsx ../tools/report-reading-acceptance/run.mts \
|
||||
--case obama --out /tmp/report-220-online
|
||||
```
|
||||
|
||||
在线模式复用生产报告写作器,默认模型为 `deepseek-flash`,可用 `--model` 指定。凭据依次读取 `DEEPSEEK_API_KEY`、`DS_KEY` 和 `/workspace/keys/ds_key`。
|
||||
|
||||
## 检查输出
|
||||
|
||||
- 输出落在 `<out>/<case>/`。`input.json` 是实际计算请求。`facts.json`、`caps.json` 和 `system_prompt.txt` 是本次使用的材料。`raw.json` 保存被选中尝试的模型回复。旧草稿回放时,它保存转换前的 JSON 值。
|
||||
- `reading.json` 是存储形状,`final.md` 复用页面的 Markdown 导出。`postcheck.json` 和 `transform_log.json` 记录每次尝试和逐节结果。整份草稿被拒时,工具仍写出诊断并返回非零状态。
|
||||
- `manifest.json` 保存其他九个文件的字节哈希。JSON 使用 UTF-16 键排序和 ECMAScript 数字表示,以紧凑 UTF-8 写出。快照中的 `stored_sha256` 对 `reading.json` 去掉 `snapshot` 后的规范化 JSON 计算。其余五个快照哈希对应输入、事实、等级合同、系统提示和原始回复文件。任何快照哈希或规则不一致都会返回非零状态。
|
||||
- 验收包写到仓库之外,留在受控验收环境。命令结束时输出本次 Git 提交及复跑命令。
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Compute a public acceptance case through the same report service as the API."""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from scripts.jyotish_api_server import JyotishComputeService
|
||||
from scripts.professional_report_reference import build_professional_report_reference
|
||||
|
||||
|
||||
def main():
|
||||
payload = json.loads(sys.stdin.buffer.read())
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
result = build_professional_report_reference(JyotishComputeService(), payload)
|
||||
sys.stdout.buffer.write(json.dumps(result["reading_facts"], ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Canonical report JSON, using UTF-16 key order and ECMAScript number spelling."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def _quote(value):
|
||||
# Combine surrogate pairs, and escape any unpaired UTF-16 code units.
|
||||
value = value.encode("utf-16-be", "surrogatepass").decode("utf-16-be", "surrogatepass")
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
return "".join(f"\\u{ord(char):04x}" if "\ud800" <= char <= "\udfff" else char for char in encoded)
|
||||
|
||||
|
||||
def _number(value):
|
||||
try:
|
||||
number = float(value)
|
||||
except OverflowError:
|
||||
return "null"
|
||||
if not math.isfinite(number):
|
||||
return "null"
|
||||
if number == 0:
|
||||
return "0"
|
||||
shortest = Decimal(repr(number))
|
||||
if 1e-6 <= abs(number) < 1e21:
|
||||
fixed = format(shortest, "f")
|
||||
return fixed.rstrip("0").rstrip(".") if "." in fixed else fixed
|
||||
mantissa, exponent = format(shortest, "e").split("e")
|
||||
if "." in mantissa:
|
||||
mantissa = mantissa.rstrip("0").rstrip(".")
|
||||
return f"{mantissa}e{int(exponent):+d}"
|
||||
|
||||
|
||||
def canonical_json(value):
|
||||
if value is None or isinstance(value, bool):
|
||||
return json.dumps(value)
|
||||
if isinstance(value, str):
|
||||
return _quote(value)
|
||||
if isinstance(value, (float, int)):
|
||||
return _number(value)
|
||||
if isinstance(value, list):
|
||||
return "[" + ",".join(map(canonical_json, value)) + "]"
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
|
||||
return "{" + ",".join(_quote(key) + ":" + canonical_json(value[key]) for key in keys) + "}"
|
||||
raise TypeError("Expected a JSON value")
|
||||
|
||||
|
||||
def canonical_sha256(value):
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for sample in json.loads(sys.stdin.buffer.read()):
|
||||
print(canonical_sha256(sample))
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "jobs",
|
||||
"rodden_rating": "AA",
|
||||
"source": "https://www.astro.com/adbvip/adbvip_02_24.htm?lang=r&nho2=57&nhor=1",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"input": {
|
||||
"year": 1955, "month": 2, "day": 24, "hour": 19, "minute": 15, "second": 0,
|
||||
"lat": 37.7749, "lon": -122.4194, "tz": -8, "gender": "male",
|
||||
"birthplace_label": "San Francisco, California, USA",
|
||||
"today": "2026-11-12", "report_mode": "historical_replay", "subject_life_status": "deceased"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "obama",
|
||||
"rodden_rating": "AA",
|
||||
"source": "https://www.astro.com/adbvip/adbvip_08_04.htm?lang=s&nho2=45783701&nhor=1",
|
||||
"timezone": "Pacific/Honolulu",
|
||||
"input": {
|
||||
"year": 1961, "month": 8, "day": 4, "hour": 19, "minute": 24, "second": 0,
|
||||
"lat": 21.3069, "lon": -157.8583, "tz": -10, "gender": "male",
|
||||
"birthplace_label": "Honolulu, Hawaii, USA",
|
||||
"today": "2026-11-12", "report_mode": "living_consultation", "subject_life_status": "living"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReportReadingWriter } from "../../frontend/src/lib/personal-report-reading.ts";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
/** The archived drafts predate paragraph bindings. Adapt their field names and recheck every paragraph. */
|
||||
export function replayDraft(raw: unknown): Pick<Awaited<ReturnType<ReportReadingWriter>>, "body" | "originalSections"> {
|
||||
let body = raw;
|
||||
if (typeof raw === "string") {
|
||||
const text = raw.trim();
|
||||
const fenced = /^```(?:json)?\s*\r?\n([\s\S]*?)\r?\n```$/i.exec(text);
|
||||
try {
|
||||
body = JSON.parse(fenced?.[1] ?? text);
|
||||
} catch {
|
||||
return { body: null };
|
||||
}
|
||||
}
|
||||
const draft = record(body);
|
||||
if (!Array.isArray(draft?.sections)) return { body };
|
||||
return {
|
||||
originalSections: draft.sections,
|
||||
body: {
|
||||
...draft,
|
||||
sections: draft.sections.map((value: unknown) => {
|
||||
const section = record(value);
|
||||
if (!Array.isArray(section?.paragraphs)) return value;
|
||||
return {
|
||||
...section,
|
||||
paragraphs: section.paragraphs.map((item: unknown) => {
|
||||
const paragraph = record(item);
|
||||
if (!paragraph || !Object.hasOwn(paragraph, "claim_type") || Object.hasOwn(paragraph, "claims")) return item;
|
||||
const { claim_type, grade, ...fields } = paragraph;
|
||||
return claim_type === null && grade === null
|
||||
? { ...fields, kind: "fact" }
|
||||
: { ...fields, claims: [claim_type], grade };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { reportReadingRules } from "../../frontend/src/lib/personal-report-reading-audit.ts";
|
||||
import { acceptReadingFacts, writeReportReading, type ReportReadingWriter } from "../../frontend/src/lib/personal-report-reading.ts";
|
||||
import { canonicalReportReadingJson, canonicalReportReadingSha256, reportReadingSha256 } from "../../frontend/src/lib/report-reading-canonical-json.ts";
|
||||
import { buildReportExportBlocks } from "../../frontend/src/lib/report-export-blocks.ts";
|
||||
import { reportReadingPrompt } from "../../frontend/src/mastra/report-reading-skill.ts";
|
||||
import { replayDraft } from "./replay-draft.mts";
|
||||
|
||||
const toolDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const repo = resolve(toolDir, "../..");
|
||||
const { values } = parseArgs({ options: {
|
||||
case: { type: "string" }, replay: { type: "string" }, facts: { type: "string" },
|
||||
out: { type: "string" }, model: { type: "string" }, python: { type: "string" },
|
||||
} });
|
||||
|
||||
function loadJson(path: string): unknown {
|
||||
const contents = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
||||
try {
|
||||
return JSON.parse(contents);
|
||||
} catch {
|
||||
throw new Error("An input file does not contain valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
type Case = { id: string; input: Record<string, unknown> };
|
||||
|
||||
function onlineWriter(modelId: string): Promise<ReportReadingWriter> {
|
||||
const keyFile = "/workspace/keys/ds_key";
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY?.trim() || process.env.DS_KEY?.trim()
|
||||
|| (existsSync(keyFile) ? readFileSync(keyFile, "utf8").trim() : "");
|
||||
if (!apiKey) throw new Error("Set DEEPSEEK_API_KEY, DS_KEY, or /workspace/keys/ds_key for online mode.");
|
||||
return import("../../frontend/src/mastra/personal-report-reading.ts").then(({ createReportReadingWriter }) =>
|
||||
createReportReadingWriter({
|
||||
id: modelId, label: modelId, description: "", creditCost: 0, isDefault: false, mode: "compatible",
|
||||
model: { providerId: "deepseek", modelId, url: "https://api.deepseek.com/v1", apiKey },
|
||||
}));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!values.case || !values.out || (values.facts && !values.replay)) {
|
||||
throw new Error("Use --case <jobs|obama> --out <dir> [--replay <raw.json> [--facts <facts.json>]].");
|
||||
}
|
||||
const sample = (loadJson(join(toolDir, "cases.json")) as Case[]).find((entry) => entry.id === values.case);
|
||||
if (!sample) throw new Error("Unknown public case. Use jobs or obama.");
|
||||
const out = resolve(values.out, sample.id);
|
||||
if (existsSync(out)) throw new Error("Choose a fresh output directory for this case.");
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim();
|
||||
const raw = values.replay ? loadJson(values.replay) : undefined;
|
||||
const provider = values.replay ? undefined : await onlineWriter(values.model ?? "deepseek-flash");
|
||||
const input = {
|
||||
...sample.input, format: "markdown", packs: ["full"], edition: "full_data", languages: ["zh", "en"],
|
||||
include_fact_tables: true, include_reading_facts: true, birth_time_accuracy: "confirmed",
|
||||
ayanamsa: "lahiri", node_mode: "mean",
|
||||
};
|
||||
const facts = acceptReadingFacts(values.facts ? loadJson(values.facts) : JSON.parse(execFileSync(
|
||||
values.python ?? "python3", [join(toolDir, "calculate_facts.py")], {
|
||||
cwd: repo, input: canonicalReportReadingJson(input), encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 300_000, stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, VEDASTRO_ENABLE_NETWORK: "0" },
|
||||
},
|
||||
)));
|
||||
if (!facts) throw new Error("The current reading facts are unavailable or invalid.");
|
||||
|
||||
const captured = new Map<string, unknown>();
|
||||
const write: ReportReadingWriter = Object.assign(async (request: Parameters<ReportReadingWriter>[0]) => {
|
||||
if (!provider) {
|
||||
captured.set(canonicalReportReadingSha256(raw), raw);
|
||||
return { ...replayDraft(raw), rawResponse: raw, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
const result = await provider(request);
|
||||
const response = result.rawResponse ?? result.body ?? null;
|
||||
captured.set(canonicalReportReadingSha256(response), response);
|
||||
return result;
|
||||
}, { prompt: provider?.prompt ?? reportReadingPrompt() });
|
||||
const outcome = await writeReportReading({
|
||||
facts, reportInput: input, birthTimeConfirmed: true, write, gitCommit: commit,
|
||||
...(values.replay ? { attempts: 1 } : {}),
|
||||
});
|
||||
const { reading, transformLog } = outcome;
|
||||
const selectedSha = reading?.snapshot.raw_response_sha256 ?? [...captured.keys()].at(-1);
|
||||
const selectedRaw = selectedSha ? captured.get(selectedSha) : null;
|
||||
const caps = facts.evidence_grades;
|
||||
const final = reading ? buildReportExportBlocks({ markdown: "", reading }).find(({ id }) => id === "body:reading")?.text : "";
|
||||
const files = {
|
||||
"input.json": canonicalReportReadingJson(input),
|
||||
"facts.json": canonicalReportReadingJson(facts),
|
||||
"caps.json": canonicalReportReadingJson(caps),
|
||||
"system_prompt.txt": write.prompt.system,
|
||||
"raw.json": canonicalReportReadingJson(selectedRaw ?? null),
|
||||
"reading.json": canonicalReportReadingJson(reading),
|
||||
"final.md": final ?? "",
|
||||
"postcheck.json": canonicalReportReadingJson({ ok: reading !== null, unavailable: outcome.unavailable, ...transformLog }),
|
||||
"transform_log.json": canonicalReportReadingJson(transformLog ?? null),
|
||||
};
|
||||
const files_sha256 = Object.fromEntries(Object.entries(files).map(([name, content]) => [name, reportReadingSha256(content)]));
|
||||
const manifest = {
|
||||
case: sample.id, mode: values.replay ? "replay" : "online", rules: reportReadingRules(commit),
|
||||
facts_source: values.facts ? "supplied" : "engine",
|
||||
input_sha256: files_sha256["input.json"], facts_sha256: files_sha256["facts.json"], caps_sha256: files_sha256["caps.json"],
|
||||
system_prompt_sha256: files_sha256["system_prompt.txt"], raw_response_sha256: files_sha256["raw.json"],
|
||||
stored_sha256: null as string | null, files_sha256,
|
||||
};
|
||||
if (reading) {
|
||||
const { snapshot, ...stored } = reading;
|
||||
manifest.stored_sha256 = canonicalReportReadingSha256(stored);
|
||||
for (const key of ["input_sha256", "facts_sha256", "caps_sha256", "system_prompt_sha256", "raw_response_sha256", "stored_sha256"] as const) {
|
||||
assert.equal(manifest[key], snapshot[key], `Snapshot mismatch: ${key}`);
|
||||
}
|
||||
assert.deepEqual(manifest.rules, snapshot.rules);
|
||||
}
|
||||
mkdirSync(resolve(values.out), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(out, { mode: 0o700 });
|
||||
for (const [name, content] of Object.entries({ ...files, "manifest.json": canonicalReportReadingJson(manifest) })) {
|
||||
writeFileSync(join(out, name), content, { flag: "wx", mode: 0o600 });
|
||||
}
|
||||
const quote = (arg: string) => process.platform === "win32"
|
||||
? `'${arg.replaceAll("'", "''")}'`
|
||||
: `'${arg.replaceAll("'", "'\\''")}'`;
|
||||
const command = [process.execPath, "--import", "tsx", ...process.argv.slice(1)].map(quote).join(" ");
|
||||
console.log(`git ${commit}\n${process.platform === "win32" ? "& " : ""}${command}`);
|
||||
if (!reading) {
|
||||
console.error(`No reading passed post-check (${outcome.unavailable}). Review postcheck.json.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
// execFile errors may contain a subprocess's payload or stdout. Report only a stable message.
|
||||
console.error(error instanceof Error && !("stdout" in error) ? error.message : "Could not calculate reading facts.");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user