Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 5ac88e58f5 让大运接口走完整解读的同一套算法,不再返回编出来的周期。
Ashtottari 在适用时把出生前已经走过的部分算进时间线。

Co-Authored-By: Grok (agent loop)
2026-10-16 00:52:29 +08:00
Claude b9cbe9aa76 merge origin/staging into python hygiene (issue #68)
Independent Staging Quality Gate / validate (push) Successful in 14m1s
Independent Staging Quality Gate / publish (push) Successful in 5m22s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 23:30:28 +08:00
Claude 5d452a4ff1 merge origin/staging into contracts helpers dedupe (issue #72)
Independent Staging Quality Gate / validate (push) Successful in 17m49s
Independent Staging Quality Gate / publish (push) Successful in 4m18s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 22:44:45 +08:00
jesse-uxandGrok (agent loop) d2296c2654 把前后端共用的请求契约和小工具收成一份,避免再各抄一份。
报告创建请求、校正入口和同名类型改从 contracts 推导;同义的对象判断与 sha256 改走共享函数。调用处用 import 别名保留原名,使 diff 低于 1500 行。行为、哈希和状态字符串不变。

Co-Authored-By: Grok (agent loop)
2026-10-15 22:22:44 +08:00
122 changed files with 1810 additions and 1283 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额
- 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。
- 完整解读里的 Ashtottari 大运日期改为从出生时已经走过的那一段算起,后面的大运、小运和「当前大运」一起平移。Skill 版本不变(BUG-1341、BUG-1344)。
## 2026-10-15 — 免费层限流状态与真实行为对齐
- 诊断和审计里的「免费层是否在限流」改为按真实限流计算。旧的三个排队开关名字还在,但不再控制限流,诊断里会标明它们已废弃。
+38 -6
View File
@@ -17314,14 +17314,14 @@
## BUG-1284 | 旧 Shoola 从月亮星座起并且逐年缩短,注册表仍留着
- 状态:mitigated
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 状态:resolved(issue #69,分支 `codex/dasha-unify-20261013`:占位注册表已删除,`/api/dasha` 的 shoola 改走 `rashi_dashas_native` 的 `shoola_forward_md_direct_ad_v1`)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-16
- 来源:`TASK-rashi-dashas-native-20261008`。
- 影响面:`scripts/extended_dashas.py` 的 `calc_shoola_dasha` 与 `'shoola'` 注册项。接口层仍能按这个键调用。完整数据版报告不走这条。
- 现象:旧函数从月亮星座起,年数从 9 减到 1,一年按 365.25636 日。所选 Rath 口径是上升或第 7 里较强的星座、一律顺行、每段 9 个日历年。
- 触发条件:调用扩展大运注册表里的 `shoola`,或报告误用该函数。
- 根因:早期简化实现留在注册表。本单不得改 `scripts/jyotish_api_server.py` 的方法体,所以注册项还在。
- 修复:报告改走 `shoola_forward_md_direct_ad_v1`。注册表和接口方法体不动。
- 修复:报告改走 `shoola_forward_md_direct_ad_v1`。2026-10-09 当时不改接口方法体。2026-10-16 issue #69 删除 `extended_dashas.py`,接口上的 shoola 改为直接复用同一段。
- 验证:三个新模块的语法树不导入 `extended_dashas`。定向 pytest 退出码 0。
- 防复发:上述导入断言。报告章不得再调用 `calc_shoola_dasha`。
- 相关记录:BUG-1283。
@@ -35412,14 +35412,14 @@
## BUG-1284 | 旧 Shoola 从月亮星座起并且逐年缩短,注册表仍留着
- 状态:mitigated
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 状态:resolved(issue #69,分支 `codex/dasha-unify-20261013`:占位注册表已删除,`/api/dasha` 的 shoola 改走 `rashi_dashas_native` 的 `shoola_forward_md_direct_ad_v1`)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-16
- 来源:`TASK-rashi-dashas-native-20261008`。
- 影响面:`scripts/extended_dashas.py` 的 `calc_shoola_dasha` 与 `'shoola'` 注册项。接口层仍能按这个键调用。完整数据版报告不走这条。
- 现象:旧函数从月亮星座起,年数从 9 减到 1,一年按 365.25636 日。所选 Rath 口径是上升或第 7 里较强的星座、一律顺行、每段 9 个日历年。
- 触发条件:调用扩展大运注册表里的 `shoola`,或报告误用该函数。
- 根因:早期简化实现留在注册表。本单不得改 `scripts/jyotish_api_server.py` 的方法体,所以注册项还在。
- 修复:报告改走 `shoola_forward_md_direct_ad_v1`。注册表和接口方法体不动。
- 修复:报告改走 `shoola_forward_md_direct_ad_v1`。2026-10-09 当时不改接口方法体。2026-10-16 issue #69 删除 `extended_dashas.py`,接口上的 shoola 改为直接复用同一段。
- 验证:三个新模块的语法树不导入 `extended_dashas`。定向 pytest 退出码 0。
- 防复发:上述导入断言。报告章不得再调用 `calc_shoola_dasha`。
- 相关记录:BUG-1283。
@@ -36319,4 +36319,36 @@
- 复发自:无
- 修复版本:分支 `codex/python-hygiene-20261013`。本号是任务书预留的空号;本分支最大已用编号是 BUG-1357,1340 未被占用,因此不顺延。
## BUG-1341 | 大运接口用占位周期冒充计算结果
- 状态:resolved
- 首次发现:2026-10-13
- 最近更新:2026-10-16
- 影响面:`POST /api/dasha` 里除 Vimshottari 以外的体系。排盘接口里的大运名单和数量不变。
- 用户现象:选 Yogini、Panchottari 等大运时,接口返回的主星和日期不随星盘变化,年数是按总数平均编的,却标成已经算过。
- 触发条件:请求一个注册表里有、但不是 Vimshottari 的大运键。
- 根因:`extended_dashas.py` 和 `conditional_dashas.py` 是早期为了凑体系数量写的占位。完整解读后来改走独立模块,接口还留在占位函数上。
- 修复:接口改为调用完整解读使用的同一批模块。没有真实现的体系返回「未实现」。占位文件删除。Narayana 仍标明旧算法,不换成另一套。shoola 复用报告里已有的那一段。
- 验证:`tests/test_dasha_api_engine_parity.py` 9 通过。任务书列出的 pytest 共 247 通过、0 失败。`tests/run_all.py` 102/102。虚构盘排盘响应除缓存时间戳外逐字段相同,`dasha_count` 仍为 35。
- 防复发:上述对照测试。响应里不再出现 `precision` 为 `generic` 或 `calculator`。
- 相关记录:BUG-1262、BUG-1284、BUG-1215、BUG-1218、BUG-1344
- 复发自:无
- 修复版本:分支 `codex/dasha-unify-20261013`。本号是任务书预留号;本分支最大已用编号是 BUG-1357,1341 与 1344 都未被占用,因此不顺延。Narayana 旧算法与 Rath 版仍未裁定。
## BUG-1344 | Ashtottari 大运从出生日满额起算,整条时间线偏了
- 状态:resolved
- 首次发现:2026-10-14
- 最近更新:2026-10-16
- 影响面:完整解读和接口里的 Ashtottari 时间线。适用规则(哪些盘适用、两种规则族怎么判定)没改。另一套从 Mars 起算的星宿大运没有用户界面在读,这次不动。
- 用户现象:适用 Ashtottari 时,第一段大运从出生时刻按整段年数起算,后面每一段都一起偏出一段出生前已经走过的时间。
- 触发条件:盘面已被判定适用,而且月亮落在起运星所辖的星宿组里。月亮经度没传、或不在该组内时,时间线仍从出生起算。
- 根因:余额只在一种规则族里计算,而且算完没有用来移动第一段的起点。完整解读调用时也没有把月亮经度传进去。
- 修复:两种规则族在适用时都按月亮在该组里已走过的比例,把第一段起点改到出生时刻减去已走过的部分。后续大运、小运和当前大运一起平移。完整解读只给这次调用补月亮经度,不改与 Yogini、Kalachakra 共用的那份出生信息。
- 验证:虚构盘与两张公开盘,起止日与 PyJHora 4.8.7 相差不超过 1 天,起运星一致;出生时刻落在第一段内;当前大运与按日期查表一致。全量解读除 Ashtottari 段和因此改变的结果哈希外,其余路径 diff 为空。
- 防复发:`tests/test_dasha_api_engine_parity.py` 里的 Ashtottari 对照。PyJHora 缺失时对照部分跳过,与引擎自身的当前大运查表仍保留。
- 相关记录:BUG-1341
- 复发自:无
- 修复版本:分支 `codex/dasha-unify-20261013`。
@@ -0,0 +1,98 @@
# PROGRESS · 契约与小工具去重 · 2026-10-13
分支 `codex/contracts-helpers-dedupe-20261013`,基线 `origin/staging` `cd7e0a34933bd288ad7a529d51477c7099e1f6b1`。只搬定义和引用。哈希字节、状态字符串、主题枚举值不变。没有改表、迁移、依赖、`page.tsx`、`scripts/jyotish_api_server.py`。没有写 BUG_HISTORY、CHANGELOG、DESIGN.md。`docs/agents/domain.md` 与 `CONTEXT.md` 里没有 EvidenceDomain,未加术语句。本分支 BUG 最大号 1357,本单没有新缺陷,不占新号。
## 做了什么
- T1:报告创建请求的 zod、默认主题、深度枚举收进 `frontend/src/lib/contracts/personal-report-request.ts`。服务端类型用 `z.infer`,客户端发送类型用 `z.input`。原导出名在 route-core、plan、contract 里 re-export。按钮删掉手写 interface。
- T2:校正状态列表与入口类型收进 `frontend/src/lib/contracts/rectification-entry.ts`。线上形状的 status 仍是 `string`。服务端类型在此之上收窄。`case-status.ts` 再导出这些数组,import 无环。客户端解析器仍接受任意字符串 status。
- T3:旧引导流程的 `EvidenceDomain` / `evidenceDomains` 改名为 `LegacyGuidedEvidenceDomain` / `legacyGuidedEvidenceDomains`(数组里的字符串没变)。出生时间证据的 `CandidateResult` 改名为 `LegacyCandidateResult`。`ChoiceKey` 只留在 `probe-explain.ts`(`choice-card.ts` 已经 import 它,反向会成环),choice-card 再导出。`ClaimStatus` 只留在 `personal-report-contract.ts`。v9 的 EvidenceDomain 与快照的 CandidateResult 没改名。
- T4:同义的对象判断和有限数判断收进 `frontend/src/lib/guards.ts`。`sha256Hex` 收进 `frontend/src/lib/hash.ts`(仅服务端)。长文、兑换码、消息正文改为调用它。
## 替换清单(文件 → 共享函数)
调用处多数保留原来的局部名字,用 import 别名接到共享函数。原因见文末「偏离」。别名不是另一份实现。
| 共享函数 | 文件 |
| --- | --- |
| `isRecord` | `generate-personal-report-button.tsx`、`personal-report-page.tsx`、`account-deletion.ts`、`skill-package-registry.ts`、`report-public-projection.ts`、`compose-receipt.ts` |
| `asRecord`(调用名本来就是 asRecord) | `varga-observations.ts`、`refinement-packet.ts`、`inference-adapter.ts`、`confirmation-gate.ts`、`ephemeris-view.ts`、`daily-starlanguage.ts`、`consultation-step-timing.ts` |
| `asRecord`(别名 `recordOf`) | `subject-birth.ts`、`consultation-subject-resolver.ts`、`chart-subject-write.ts` |
| `asRecord`(别名 `record`,非数组否则 null) | `birth-profile-timezone.ts`、`chart-view-mapper.ts`、`consultation-route-service.ts`、`general-daily-panchanga.ts`、`personal-report-longform-generate.ts`、`rectification-adopted-chart-tiers.ts`、`reply-quality-capture.ts`、`report-birth-binding.ts`、`v9/engine-client.ts`、`v9/segment-contract.ts`、`personal-report-route-core.ts`、`personal-report-worker.ts`、`personal-report-generation.ts` |
| `recordOrEmpty`(别名 `record`,非数组否则 `{}`) | `consultation-workflow.ts`、`chart-birth-truth.ts`、`consultation-evidence-card.ts`、`consultation-judgment-table.ts`、`admin/rectification-telemetry-summary.ts`、`report-fact-table-values.ts`、`report-fact-tables.ts` |
| `finiteNumberOrNull`(别名 `finiteNumber`) | `personal-report-route-core.ts`、`personal-report-worker.ts`、`personal-report-generation.ts`、`rectification-candidate-result.ts` |
| `sha256Hex` | `personal-report-generation.ts` 再导出;`hashLongformMarkdown`、`hashRedeemCode`、`messageContentHash` 改为调用它 |
## 未合并及原因
| 留下的私有函数 | 原因 |
| --- | --- |
| `rectification-candidate-result.ts` 的 `function record` | 数组也当对象,否则 null。与 `asRecord` 不同义 |
| `geoapify-location-service.ts` 的 `function record` | 数组也当对象,否则 `{}` |
| `server-owned-birth-profile.ts` 的 `function finiteNumber` | 接受数字字符串,失败返回 undefined |
| `chart-profiles/route.ts`、`chart-profiles/[id]/route.ts`、`synastry-reports/route.ts`、`admin/admin-error-response.ts` 的 `errorMessage` | 三种错误提取方式,任务书要求不并 |
| `personal-report-service-core.ts` 的 `PERSONAL_REPORT_DEPTHS` | 名字和用途都不是 T1 的 `REPORT_DEPTHS`,没有硬合 |
任务书 §2.3 把「含数组否则 null」记在 `rectification-adopted-chart-tiers.ts`。当前 staging 上该函数已经排除数组,所以并入 `asRecord`。真正含数组否则 null 的那份在 `rectification-candidate-result.ts`,保留。
## 断言
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| `workflow.indexOf("function record(")` | `workflow.indexOf("function rectificationBoundaryFromGate(")` | 非数组否则空对象的私有 `record` 已删除。切片终点改到紧随其后的既有函数。锁住的 schema 字段没改,`chart: z.record(z.unknown())` 仍在 |
文件:`frontend/tests/consultation-workflow-contract.test.ts`。其余点名测试没有改断言。
## 仅 import
这些文件只改了 import 行,esbuild 转译结果与 staging 相同:
`rectification-choice-card.tsx`、`rectification-message-entry.tsx`、`use-rectification-case-snapshot.ts`、`answer-choice.ts`、`block-scan.ts`、`choice-action.ts`、`turn-intent-classifier.ts`、`turn-question.ts`、`window-widen.ts`、`rectification-chat-choice-run.ts`。
## esbuild 差分
对 `frontend/src` 里每个改动文件,用 esbuild `transform`(`.ts` / `.tsx`,`format=esm`,不打包)对比 `origin/staging` 与工作区。结果:相同 26,有差异 49,新文件 4,转译失败 0。
新文件:`contracts/personal-report-request.ts`、`contracts/rectification-entry.ts`、`guards.ts`、`hash.ts`。
有差异的原因,按文件:
| 文件 | 原因 |
| --- | --- |
| `generate-personal-report-button.tsx` | 手写请求类型删除,默认主题改为从 contracts 取值 |
| `personal-report-page.tsx`、`account-deletion.ts`、`compose-receipt.ts`、`report-public-projection.ts`、`skill-package-registry.ts` | 本地 `isRecord` 删除,改为 import |
| `consultation-step-timing.ts`、`daily-starlanguage.ts`、`ephemeris-view.ts`、`confirmation-gate.ts`、`inference-adapter.ts`、`refinement-packet.ts`、`varga-observations.ts` | 本地 `asRecord` 删除,改为 import |
| `subject-birth.ts`、`consultation-subject-resolver.ts`、`chart-subject-write.ts` | 本地 `recordOf` 删除,import `asRecord` 并别名为 `recordOf` |
| `birth-profile-timezone.ts`、`chart-view-mapper.ts`、`consultation-route-service.ts`、`general-daily-panchanga.ts`、`personal-report-longform-generate.ts`、`rectification-adopted-chart-tiers.ts`、`reply-quality-capture.ts`、`report-birth-binding.ts`、`engine-client.ts`、`segment-contract.ts` | 本地「非数组否则 null」的 `record` 删除,import `asRecord` 并别名为 `record` |
| `consultation-workflow.ts`、`chart-birth-truth.ts`、`consultation-evidence-card.ts`、`consultation-judgment-table.ts`、`rectification-telemetry-summary.ts`、`report-fact-table-values.ts`、`report-fact-tables.ts` | 本地「非数组否则空对象」的 `record` 删除,import `recordOrEmpty` 并别名为 `record`。`z.record(...)` 没改 |
| `rectification-candidate-result.ts` | 只把「有限数否则 null」改为 import。含数组的 `record` 仍在文件里 |
| `personal-report-route-core.ts`、`personal-report-worker.ts` | 同上,再加上 schema / 调用处的 guard import |
| `personal-report-generation.ts` | guard 搬家,并且 `sha256Hex` 改为从 `hash.ts` 再导出 |
| `personal-report-longform-appendix.ts`、`supabase/codes.ts`、`message-origin.ts` | 哈希函数体改为调用 `sha256Hex`。输入仍按 utf8 |
| `personal-report-plan.ts`、`personal-report-contract.ts` | `REPORT_DEPTHS` 字面量搬到 contracts,本文件 re-export |
| `case-status.ts` | 状态数组搬到 contracts,本文件 re-export。数组内容相同 |
| `rectification-entry.ts` | 本地状态列表删除,Set 改用共享常量 |
| `birth-time-question-planner.ts`、`birth-time-journey-turn.ts`、`birth-time-journey-transitions.ts`、`birth-time-journey-turn-persistence.ts`、`birth-time-journey-case-loader.ts` | 运行时常量改名 `legacyGuidedEvidenceDomains`。不打包,所以导出名出现在转译结果里。数组里的字符串没变 |
相同的 26 个文件里,上面「仅 import」10 个只动了 import。其余是类型改名或类型再导出,类型擦除后转译结果与 staging 相同(含 `open-request.ts`、`choice-card.ts`、`report-evidence-bundle-v2.ts`、`birth-time-evidence.ts` 及其余 Legacy* 引用)。
## 测试
开工没有跑全量 `npm test`(本轮不做全量,也没有新的全量基线数字)。完工数字:
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend` 下 `tsc --noEmit -p .` | 0 错 |
| 规范 | `frontend` 下 `npm run lint` | 0 错,123 条警告。警告是原来就有的。本轮删过 `personal-report-worker.ts` 里不再使用的 `JsonRecord`,没有留下新警告 |
| 改到的测试与任务书点名的测试 | `node node_modules/tsx/dist/cli.mjs --test`(guards、报告入口、首页校正解析、校正契约、报告生成与长文哈希、咨询 workflow 契约、出生时间改名相关) | 209 通过,0 失败,0 取消,0 跳过 |
没有跑前端全量 `npm test`、Python 快速门、`test:db`。`supabase/codes.ts` 只改了哈希函数体,没有改查询、表或迁移。
## 偏离
1. 调用处没有改成共享函数名,改用 import 别名。把调用处全部改名时,已跟踪 diff 为 621 增、852 删(合计 1473),加上当时的新文件 248 行,约 1721 行,超过硬红线 1500。别名之后已跟踪 diff 为 229 增、461 删。共享函数仍只有 `guards.ts` / `hash.ts` 里的那一份。
2. 报告按钮是客户端组件,但它要取值 `DEFAULT_REPORT_THEMES`,而这份常量和 zod schema 在同一个文件。因此这个按钮会把 zod 带进自己的包。任务书允许「zod 已在该组件 bundle 内」的例外;首屏 gzip 由验收侧看。测试仍从按钮 re-export 引用该常量。
3. `LegacyCandidateResult` 的引用比任务书点名的文件多(向导、旅程加载等)。不改的话 `tsc` 会报错,所以一并改了类型名,没有改运行时值。
4. 含数组变体的文件名与任务书 §2.3 不一致,以当前 staging 的函数体为准,见上表。
完工 diff:已跟踪 229 增 + 461 删,新文件与本记录 345 行,合计 1035,低于 1500。
@@ -0,0 +1,77 @@
# PROGRESS · dasha-unify · 2026-10-16
分支 `codex/dasha-unify-20261013`,从 `origin/staging` `b9cbe9aa` 拉出。issue #69。
## 结论
`/api/dasha` 除 Vimshottari 外改为调用完整解读同一批模块。没有真实现的体系返回「未实现」,不再编周期。占位文件 `scripts/extended_dashas.py`、`scripts/conditional_dashas.py` 已删除。Ashtottari 在已判定适用、且月亮落在起运星宿组内时,第一段从出生时刻减去已走过的部分起算。Narayana 仍标「旧算法」,没有换成 Rath 版。
## 映射(最终)
| 注册键 | 实现 |
| --- | --- |
| vimshottari | 原 handler 分支,`domain_calculation_service.compute_vimshottari_timeline` |
| yogini / ashtottari / kalachakra | 与完整解读相同的函数和入参。Ashtottari 额外带月亮经度 |
| panchottari、satabdika、chaturaaseeti、shasti_hayani、shodasottari、dwadashottari、dwisaptati、shattrimsa | 独立模块 `calculate_*`,入参与 `conditional_birth_info` 相同,并带 `PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE`。不传强制出表档,不适用的盘主运为空 |
| narayana | `narayana_dasha_full_report` + `label_legacy_narayana` |
| shoola | `rashi_dashas_native.calculate_rashi_dashas` 的 `shoola` 段(`shoola_forward_md_direct_ad_v1`) |
| chara | 400,消息含 `/api/dasha/chara` |
| 其余 | 400,`dasha system not implemented` |
`precision`:权威实现为 `engine_module`。删掉 `generic` 和 `calculator`。
## 基线红 / 修后绿
开工时测试还没写,没有在旧代码上跑新 pytest。占位输出用 `git show HEAD:scripts/extended_dashas.py` 复现(虚构盘、月宿 22):
| 体系 | 占位前 3 段 |
| --- | --- |
| panchottari | Ketu 1990-01-01、Venus 2002-01-01、Sun 2014-01-01 |
| satabdika | Ketu 1990-01-01、Venus 2001-01-01、Sun 2012-01-02 |
| chaturaaseeti | Ketu 1990-01-01、Venus 1999-01-01、Sun 2008-01-02 |
| narayana | Pisces 3 年、Capricorn 6 年、Aquarius 9 年 |
修后 `tests/test_dasha_api_engine_parity.py`:9 通过,0 失败。接口主运与完整解读同一模块逐项相等。Panchottari / Satabdika / Chaturshitisama 在标准出表档下,主运起始日与 PyJHora 4.8.7 逐日相同。Ashtottari 三张盘(虚构盘走默认规则族,一张公开盘走 Rahu 规则族,另一张公开盘走默认规则族)起止日与 PyJHora 相差不超过 1 天,起运星一致,出生时刻落在第一段内,当前大运与按日期查表一致。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 任务书 pytest 加年长对齐 | `.venv\Scripts\python.exe -m pytest` 任务书第 8 节文件 + `tests/test_dasha_year_length_alignment.py` | 247 通过,0 失败,退出码 0 |
| release 门 | `.venv\Scripts\python.exe tests\run_all.py` | 102/102,失败 0,0.2 秒 |
| 排盘字节 | 清空 `scratch/local/api_chart_cache` 后 `_compute_chart_sync`,对照开工前基线 | 94348 字节。8 处差异全是语义缓存的 `cache_created_at` / `cache_expires_at`。`available_dashas` 与 `dasha_count` 35 未变 |
| 全量解读字节 | 同一虚构盘、`PYTHONHASHSEED=0`、`VEDASTRO_ENABLE_NETWORK=0`、today 与 transit-date 2026-01-01 | 去掉墙钟字段后 3492 条差异:3490 条在 `modules.ashtottari_dasha`;另外 2 条是顶层 `result_hash` 与 `result_binding.result_hash`(同一哈希,因 Ashtottari 进入绑定载荷)。`input_hash` 未变。其余模块 diff 为空 |
类方法数基线:原值 218,新值 217,原因是删除 `_fallback_dasha_periods`,没有新增方法。行数基线仍是 10924(实测 10906,净减少,不下调基线)。
## T4 年数表
已改为引用 `scripts/dasha_common.py` 的 `VIMSHOTTARI_YEARS`(顺序 Ketu、Venus、Sun、Moon、Mars、Rahu、Jupiter、Saturn、Mercury)。替换前用 `HEAD` 字面量核对 `list(items)` 一致的文件:
- `scripts/jyotish_engine.py` 的 `DASHA_YEARS`
- `scripts/dasha_calculator.py`、`scripts/dasha_calculator_enhanced.py` 的 `VIMSHOTTARI_PERIODS`
- `scripts/vimshottari_subperiod_timeline.py` 的 `DASHA_YEARS`
- `scripts/event_prediction_model.py` 的 `DASHA_YEARS`
- `scripts/nakshatra_dasha.py` 的 Vimshottari 年数表(`calc_ashtottari_dasha` 未改)
- `scripts/prashna.py` 的 `VIM_DURATIONS`
- `scripts/validate.py` 的 `DASHA_YEARS`
- `scripts/kp_system.py` 的年限列表 `[7, 20, 6, 10, 7, 18, 16, 19, 17]`,按原 `KP_LORDS` 顺序取共享表
- `scripts/tajika.py` 两处函数内字面量
未换、也未改:冻结的 `scripts/dasha_analyzer.py`;`scripts/divisional_charts_extended.py` 根上没有这份年数表;`scripts/research/offline_research_20260926_lib.py` 属于研究脚本。六个条件大运的 `_birth_lord_and_balance` 改为一行调用。2000 个固定种子月亮经度与源码副本公式 `==`,0 差异。`shastihayani`、`dwadashottari` 的余额函数未动。
## 冻结文件
`sealed_holdout_rerun` 的 PRODUCTION / RESEARCH 与 `minute_rectification_holdout_v3.json` 的 `frozen_scoring.files` 均未出现在本次 diff 中。未改 `narayana_dasha.py`、`narayana_rath.py`、`narayana_legacy_label.py`、`domain_calculation_service.py`、`dasha_analyzer.py`。
## 历史证据未改
`references/oracle/full_technique_invocation_matrix_2026_07_22.json`、`references/oracle/rectification_missing_layer_integration_plan_2026_07_19.json` 仍写着已删除文件的路径。`references/cross_project_contract/sync_ledger.json` 只提到测试文件名。这些 JSON 不改。
## 偏离
- 入参构造放在 `scripts/dasha_registry.py`,没有从 `cmd_full_reading` 抽出共享函数,避免改动全量解读里 Yogini / Kalachakra 的出生信息。等价由对照测试锁住。
- `scripts/orchestrator_bridge.py` 的 Ashtottari 调用已经从盘面带上月亮经度,这次没有改代码。
- `nakshatra_dasha.calc_ashtottari_dasha`(从 Mars 起算)前端没有读取。全量解读字节对照里它没有变化。留到后续阶段,不在本单替换。
- 新 pytest 没有在改代码之前跑红。红的证据是上面从 `HEAD` 复现的占位输出,加上任务书第 2.1 节的对照表。
- PyJHora 4.8.7 只装在本工作树的虚拟环境里做对照,没有写入依赖清单。
@@ -3,7 +3,7 @@
import { useState } from "react";
import { lifeEventSchema } from "@/lib/birth-time-evidence";
import type { EvidenceDraft } from "@/lib/birth-time-journey-turn";
import type { EvidenceDatePrecision, EvidenceDomain } from "@/lib/birth-time-question-planner";
import type { EvidenceDatePrecision, LegacyGuidedEvidenceDomain } from "@/lib/birth-time-question-planner";
const domainLabels = {
education: "学业与学习环境",
@@ -12,7 +12,7 @@ const domainLabels = {
career: "工作与身份变化",
finance: "收入、资产或资源变化",
health_pressure: "健康或生活压力",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
type DraftCardProps = {
readonly draft: EvidenceDraft;
@@ -14,7 +14,11 @@
"use client";
import { useRef, useState } from "react";
import { isRecord } from "@/lib/guards";
import {
DEFAULT_REPORT_THEMES,
type PersonalReportCreateRequestInput,
} from "@/lib/contracts/personal-report-request";
import { useRouter } from "next/navigation";
import { readCurrentSubjectId } from "@/lib/current-subject";
import { navigateAppPath } from "@/lib/app-navigation";
@@ -24,16 +28,7 @@ import { notifyError } from "@/lib/notify";
import { Button } from "@/components/ui/button";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing", "health"] as const;
export interface PersonalReportCreateRequest {
requestId: string;
reportType: "personal_full";
presentationMode: "default";
depth: "standard";
themes: readonly string[];
chartProfileId?: string;
}
export { DEFAULT_REPORT_THEMES };
/**
* uuid v4 via crypto.randomUUID (secure contexts). Returns null when the
@@ -52,7 +47,11 @@ export function createReportRequestId(): string | null {
/** Request shape for POST /api/reports. Never includes birth data or chat text. */
export function buildPersonalReportCreateRequest(
requestId: string,
): PersonalReportCreateRequest {
): PersonalReportCreateRequestInput & {
reportType: "personal_full";
presentationMode: "default";
depth: "standard";
} {
const subjectId = readCurrentSubjectId();
return {
requestId,
@@ -68,10 +67,6 @@ export type CreateReportOutcome =
| { kind: "navigate"; reportId: string }
| { kind: "hint"; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readReportId(json: unknown): string | null {
if (!isRecord(json) || !isRecord(json.report)) {
return null;
@@ -10,6 +10,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { isRecord } from "@/lib/guards";
import Link from "next/link";
@@ -195,10 +196,6 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readSubjectDisplayName(json: unknown): string | undefined {
if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined;
const trimmed = json.subjectDisplayName.trim();
@@ -4,9 +4,9 @@ import { useState } from "react";
import { InlineSpinner } from "@/components/inline-spinner";
import {
CHOICE_STOP_LABEL,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCard,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
@@ -22,9 +22,9 @@ import {
CHOICE_SKIP_QUESTION_MESSAGE,
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
import {
questionIsAnswered,
questionIsDeadUnanswered,
@@ -22,9 +22,9 @@ import {
caseSnapshotState,
currentQuestionFromSnapshot,
questionSourceFromSnapshot,
type CandidateResult,
type CurrentQuestionModel,
} from "@/lib/rectification-chat-snapshot";
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
/**
* Case snapshot sync for the rectification chat surface: the state that the
+1 -4
View File
@@ -1,3 +1,4 @@
import { isRecord } from "@/lib/guards";
/**
* Self-service account deletion (2026-09-30). Shared by the settings entry,
* the frozen-account screen, the API route and the purge worker. Pure: no
@@ -52,10 +53,6 @@ export type AccountDeletionStatus =
| Readonly<{ status: "none" }>
| Readonly<{ status: "pending"; requestedAt: string | null; scheduledFor: string }>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isoOrNull(value: unknown): string | null {
if (typeof value !== "string" && !(value instanceof Date)) return null;
const date = new Date(value);
@@ -1,3 +1,4 @@
import { recordOrEmpty as record } from "@/lib/guards";
/**
* Admin view of the anonymous rectification telemetry
* (TASK-rectification-telemetry-20260926, D3).
@@ -113,12 +114,6 @@ export const TELEMETRY_GAP_BUCKET_LABEL: Readonly<Record<string, string>> = {
export const TELEMETRY_PERCENT_SHOWN_BUCKETS: readonly string[] = ["5-9", "10+"];
function record(value: unknown): Readonly<Record<string, unknown>> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function numberOrNull(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
+1 -6
View File
@@ -1,3 +1,4 @@
import { asRecord as record } from "@/lib/guards";
type RecordValue = Record<string, unknown>;
export class BirthProfileTimezoneError extends Error {
@@ -7,12 +8,6 @@ export class BirthProfileTimezoneError extends Error {
}
}
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function valueFor(profile: RecordValue, snakeKey: string, camelKey: string): unknown {
return profile[snakeKey] ?? profile[camelKey];
}
@@ -11,7 +11,7 @@ import {
timeRangeSchema,
validateOptionSet,
} from "./birth-time-dynamic-choice.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
import type { DynamicActionReceipt } from "./birth-time-dynamic-action-receipt.ts";
import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts";
@@ -99,7 +99,7 @@ export type ServerChoiceEvidence = {
};
export type DynamicChoiceScoringResult = {
readonly candidate: CandidateResult;
readonly candidate: LegacyCandidateResult;
readonly evidenceMode: "dynamic_choice";
readonly effectiveAnswerCount: number;
readonly dimensionCount: number;
@@ -1,7 +1,7 @@
import { parseJourneyResponse } from "./birth-time-journey-client.ts";
import type { DynamicJourneyClientResponse } from "./birth-time-journey-response-schema.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7";
@@ -52,7 +52,7 @@ const candidate = {
reasons: [],
evidence: [],
algorithmVersion: "birth-time-dynamic-scoring-v2",
} satisfies CandidateResult;
} satisfies LegacyCandidateResult;
const highCandidate = {
...candidate,
@@ -62,7 +62,7 @@ const highCandidate = {
eventCount: 4,
domainCount: 3,
marginPercent: 24,
} satisfies CandidateResult;
} satisfies LegacyCandidateResult;
const terminalLowCandidate = {
...candidate,
@@ -75,7 +75,7 @@ const terminalLowCandidate = {
},
eventCount: 4,
domainCount: 3,
} satisfies CandidateResult;
} satisfies LegacyCandidateResult;
function response(input: {
readonly nextAction: DynamicNextAction;
@@ -83,7 +83,7 @@ function response(input: {
readonly turnVersion?: number;
readonly answeredCount?: number;
readonly effectiveAnswerCount?: number;
readonly candidateResult?: CandidateResult | null;
readonly candidateResult?: LegacyCandidateResult | null;
readonly snapshotState?: "rectifying" | "candidate" | "confirming" | "ready";
readonly activeTime?: string | null;
}): DynamicJourneyClientResponse {
@@ -1,9 +1,9 @@
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
export type DynamicStopInput = {
readonly result: CandidateResult | null;
readonly result: LegacyCandidateResult | null;
readonly effectiveAnswer: boolean;
readonly previousResult: CandidateResult | null;
readonly previousResult: LegacyCandidateResult | null;
readonly priorPlateauCount: number;
readonly usefulOpportunityCount: number;
readonly repeatedOnly: boolean;
@@ -25,8 +25,8 @@ export type DynamicStopDecision =
| { readonly kind: "continue"; readonly plateauCount: number };
export function materiallyChanged(
previousResult: CandidateResult | null,
result: CandidateResult,
previousResult: LegacyCandidateResult | null,
result: LegacyCandidateResult,
): boolean {
if (previousResult === null) return true;
const previousRange = previousResult.winningSegment;
@@ -1,5 +1,5 @@
import { withCandidateResult } from "./birth-time-evidence.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
import { decideDynamicStop } from "./birth-time-dynamic-stop-policy.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import type {
@@ -152,7 +152,7 @@ export function answerTransition(input: {
export function completeDynamicScoreTransition(input: {
readonly stored: DynamicStoredRectificationCase;
readonly candidate: CandidateResult;
readonly candidate: LegacyCandidateResult;
readonly usefulOpportunityCount: number;
readonly repeatedOnly: boolean;
readonly nextVersion: number;
@@ -2,7 +2,7 @@ import {
withCandidateResult,
withConfirmedCandidate,
} from "./birth-time-evidence.ts";
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type {
BirthTimeJourneyPorts,
JourneyResponse,
@@ -61,7 +61,7 @@ export function assertLegacyJourneyMutation(
function response(
stored: StoredRectificationCase,
lifeEvents: readonly LifeEvent[],
candidateResult: CandidateResult | null,
candidateResult: LegacyCandidateResult | null,
): JourneyResponse {
return {
caseId: stored.id,
+5 -5
View File
@@ -174,11 +174,11 @@ export const candidateResultSchema = z.object({
}
});
export type CandidateResult = z.infer<typeof candidateResultSchema>;
export type LegacyCandidateResult = z.infer<typeof candidateResultSchema>;
function highCandidateMeetsSafetyGates(value: {
readonly confidence: CandidateResult["confidence"];
readonly winningSegment: CandidateResult["winningSegment"];
readonly confidence: LegacyCandidateResult["confidence"];
readonly winningSegment: LegacyCandidateResult["winningSegment"];
readonly eventCount: number;
readonly domainCount: number;
readonly marginPercent: number;
@@ -197,7 +197,7 @@ export class CandidateConfirmationError extends Error {
export function withCandidateResult(
snapshot: JourneySnapshot,
result: CandidateResult,
result: LegacyCandidateResult,
): JourneySnapshot {
switch (result.confidence) {
case "low":
@@ -246,7 +246,7 @@ export function withCandidateResult(
export function withConfirmedCandidate(
snapshot: JourneySnapshot,
result: CandidateResult,
result: LegacyCandidateResult,
confirmedTime: string,
): JourneySnapshot {
if (
+4 -4
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { lifeEventSchema } from "./birth-time-evidence.ts";
import type {
EvidenceDatePrecision,
EvidenceDomain,
LegacyGuidedEvidenceDomain,
QuestionSpec,
} from "./birth-time-question-planner.ts";
@@ -71,7 +71,7 @@ export const guideDraftEnvelopeSchema = z.object({
export type GuideQuestionResponse = z.infer<typeof guideQuestionResponseSchema>;
export type ParsedEvidenceDraft = {
readonly domain: EvidenceDomain;
readonly domain: LegacyGuidedEvidenceDomain;
readonly precision: EvidenceDatePrecision | null;
readonly date: string | null;
readonly needsReview: boolean;
@@ -100,7 +100,7 @@ const subjectByDomain = {
career: "一次明显的工作、职业方向或身份变化",
finance: "一次收入、资产、负债或资源渠道的明显变化",
health_pressure: "一次持续的健康压力或生活压力变化",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
function precisionQuestion(question: QuestionSpec): string {
const requested = new Set(question.requestedPrecision);
@@ -187,7 +187,7 @@ function dateIsGrounded(date: string, sourceMessage: string): boolean {
export function parseEvidenceDraftOutput(
value: unknown,
context: {
readonly requiredDomain: EvidenceDomain;
readonly requiredDomain: LegacyGuidedEvidenceDomain;
readonly sourceMessage?: string;
},
): ParsedEvidenceDraft {
@@ -4,7 +4,7 @@ import {
candidateResultSchema,
type BirthTimeAssessment,
} from "./birth-time-journey.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
import type {
RectificationAnswer,
RectificationQuestion,
@@ -260,11 +260,11 @@ export function parseRectificationAnswer(value: unknown): RectificationAnswer {
return z.enum(["A", "B", "C", "D"]).parse(value);
}
export function parseCandidateResult(value: unknown): CandidateResult {
export function parseCandidateResult(value: unknown): LegacyCandidateResult {
return adaptCandidateResult(candidateResultApiSchema.parse(value));
}
function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>): CandidateResult {
function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>): LegacyCandidateResult {
return candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
@@ -10,7 +10,7 @@ import {
lifeEventSchema,
} from "./birth-time-journey.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import { legacyGuidedEvidenceDomains } from "./birth-time-question-planner.ts";
import {
dynamicJourneyTurnStateSchema,
evidenceDraftSchema,
@@ -73,7 +73,7 @@ const storedCaseSchema = z.object({
evidence_draft: z.unknown().nullable().default(null),
processed_action_ids: z.array(z.string().uuid()).default([]),
adaptive_round: z.number().int().min(0).max(3).default(0),
asked_domains: z.array(z.enum(evidenceDomains)).default([]),
asked_domains: z.array(z.enum(legacyGuidedEvidenceDomains)).default([]),
});
const eventLocationSchema = z.object({
latitude: z.number(),
@@ -1,6 +1,6 @@
import {
withCandidateResult,
type CandidateResult,
type LegacyCandidateResult,
} from "./birth-time-evidence.ts";
import { terminalJourneySnapshot } from "./birth-time-journey-response.ts";
import {
@@ -11,13 +11,13 @@ import type { LegacyStoredRectificationCase as StoredRectificationCase } from ".
type CompleteScoreTransitionInput = {
readonly stored: StoredRectificationCase;
readonly candidateResult: CandidateResult;
readonly candidateResult: LegacyCandidateResult;
readonly nextVersion: number;
};
function completedTurn(
stored: StoredRectificationCase,
candidateResult: CandidateResult,
candidateResult: LegacyCandidateResult,
nextVersion: number,
): JourneyTurnState {
return projectJourneyTurn({
@@ -1,7 +1,7 @@
import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts";
import { createJourneyTurnActions } from "./birth-time-journey-actions.ts";
import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts";
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type { CandidateVargaSample } from "./birth-time-question-planner.ts";
import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
@@ -97,7 +97,7 @@ export type DynamicChoiceScoreInput = Pick<DifferencePacketInput, "birthDate" |
export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
score(input: JourneyScoreInput): Promise<RectificationScoringResult>;
scoreEvents(input: JourneyEventScoreInput): Promise<CandidateResult>;
scoreEvents(input: JourneyEventScoreInput): Promise<LegacyCandidateResult>;
buildDifferencePacket(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
}
@@ -127,7 +127,7 @@ type StoredRectificationCaseBase = {
readonly ayanamsa?: AyanamsaName;
};
readonly lifeEvents?: readonly LifeEvent[];
readonly candidateResult?: CandidateResult | null;
readonly candidateResult?: LegacyCandidateResult | null;
};
export type LegacyStoredRectificationCase = StoredRectificationCaseBase & LegacyStoredFields;
@@ -189,7 +189,7 @@ export type JourneyResponseBase = {
readonly scoring: RectificationScoringResult | null;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
readonly lifeEvents: readonly LifeEvent[];
readonly candidateResult: CandidateResult | null;
readonly candidateResult: LegacyCandidateResult | null;
};
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState
@@ -6,11 +6,11 @@ import type {
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
import type { LegacyGuidedEvidenceDomain } from "./birth-time-question-planner.ts";
type LegacyProgress = {
readonly adaptiveRound: number;
readonly askedDomains: readonly EvidenceDomain[];
readonly askedDomains: readonly LegacyGuidedEvidenceDomain[];
};
export type LegacyStoredFields = {
@@ -7,9 +7,9 @@ import {
type NextAction,
} from "./birth-time-journey-turn.ts";
import {
evidenceDomains,
legacyGuidedEvidenceDomains,
planEvidenceQuestion,
type EvidenceDomain,
type LegacyGuidedEvidenceDomain,
type EvidenceQuestionPhase,
type QuestionSpec,
} from "./birth-time-question-planner.ts";
@@ -22,19 +22,19 @@ import type { LegacyStoredRectificationCase as StoredRectificationCase } from ".
type PlannedTurnInput = {
readonly stored: StoredRectificationCase;
readonly phase: EvidenceQuestionPhase;
readonly askedDomains: readonly EvidenceDomain[];
readonly askedDomains: readonly LegacyGuidedEvidenceDomain[];
readonly adaptiveRound: number;
readonly turnVersion: number;
};
export type EvidenceQuestionIdentity = Pick<QuestionSpec, "questionId" | "phase" | "domain">;
function coveredDomains(events: readonly LifeEvent[]): readonly EvidenceDomain[] {
return evidenceDomains.filter((domain) => events.some((event) => event.domain === domain));
function coveredDomains(events: readonly LifeEvent[]): readonly LegacyGuidedEvidenceDomain[] {
return legacyGuidedEvidenceDomains.filter((domain) => events.some((event) => event.domain === domain));
}
export function canonicalAskedDomains(domains: readonly EvidenceDomain[]): readonly EvidenceDomain[] {
return evidenceDomains.filter((domain) => domains.includes(domain));
export function canonicalAskedDomains(domains: readonly LegacyGuidedEvidenceDomain[]): readonly LegacyGuidedEvidenceDomain[] {
return legacyGuidedEvidenceDomains.filter((domain) => domains.includes(domain));
}
function progress(
@@ -2,8 +2,8 @@ import type { DataClient } from "@/lib/db/data-client";
import { z } from "zod";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
import { legacyGuidedEvidenceDomains } from "./birth-time-question-planner.ts";
import type { LegacyGuidedEvidenceDomain } from "./birth-time-question-planner.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
import {
BirthTimeJourneyStoreError,
@@ -17,7 +17,7 @@ export { BirthTimeJourneyStoreError, StaleJourneyTurnError } from "./birth-time-
export type PersistedJourneyProgress = {
readonly adaptiveRound: number;
readonly askedDomains: readonly EvidenceDomain[];
readonly askedDomains: readonly LegacyGuidedEvidenceDomain[];
};
export type PersistedJourneyTurn = {
@@ -51,8 +51,8 @@ export function profileStatus(snapshot: JourneySnapshot) {
return caseStatus(snapshot);
}
function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly EvidenceDomain[] {
return evidenceDomains.filter((domain) => input.includes(domain));
function canonicalAskedDomains(input: readonly LegacyGuidedEvidenceDomain[]): readonly LegacyGuidedEvidenceDomain[] {
return legacyGuidedEvidenceDomains.filter((domain) => input.includes(domain));
}
export function createJourneyTurnPersistence(
+8 -8
View File
@@ -1,5 +1,5 @@
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import { evidenceDomains, planEvidenceQuestion, type CandidateVargaSample, type EvidenceDomain, type QuestionSpec } from "./birth-time-question-planner.ts";
import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import { legacyGuidedEvidenceDomains, planEvidenceQuestion, type CandidateVargaSample, type LegacyGuidedEvidenceDomain, type QuestionSpec } from "./birth-time-question-planner.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { EvidenceDraft, JourneyPermissions, JourneyProgress, JourneyTurnState, NextAction } from "./birth-time-journey-turn-protocol.ts";
@@ -20,7 +20,7 @@ export type {
export type DeriveNextActionInput = {
readonly progress: JourneyProgress;
readonly candidateResult?: CandidateResult | null;
readonly candidateResult?: LegacyCandidateResult | null;
readonly nextQuestion?: QuestionSpec | null;
readonly confirmedTime?: string | null;
readonly evidenceDraft?: EvidenceDraft | null;
@@ -114,7 +114,7 @@ export function deriveNextAction(input: DeriveNextActionInput): NextAction {
}
export function deriveJourneyPermissions(
candidateResult: CandidateResult | null,
candidateResult: LegacyCandidateResult | null,
confirmedTime: string | null,
): JourneyPermissions {
return {
@@ -158,14 +158,14 @@ export type JourneyTurnProjectionInput = {
readonly questionnaire: { readonly samples: readonly CandidateVargaSample[] } | null;
readonly persistedProgress?: {
readonly adaptiveRound: number;
readonly askedDomains: readonly EvidenceDomain[];
readonly askedDomains: readonly LegacyGuidedEvidenceDomain[];
} | null;
readonly candidateResult: CandidateResult | null;
readonly candidateResult: LegacyCandidateResult | null;
readonly serverSelectedQuestion?: { readonly id: string; readonly domain?: string } | null;
readonly lifeEvents: readonly LifeEvent[];
};
const serverDomainMap: Readonly<Record<string, EvidenceDomain>> = {
const serverDomainMap: Readonly<Record<string, LegacyGuidedEvidenceDomain>> = {
education: "education",
relocation: "relocation",
residence: "relocation",
@@ -220,7 +220,7 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu
const confirmedTime = input.snapshot.state === "ready" ? input.snapshot.activeTime : null;
const coveredDomains = [...new Set(input.lifeEvents.map((event) => event.domain))];
const adaptiveRound = input.persistedProgress?.adaptiveRound ?? 0;
const askedDomains = evidenceDomains.filter((domain) => coveredDomains.includes(domain) || input.persistedProgress?.askedDomains.includes(domain) === true);
const askedDomains = legacyGuidedEvidenceDomains.filter((domain) => coveredDomains.includes(domain) || input.persistedProgress?.askedDomains.includes(domain) === true);
const displayedAdaptiveRound = input.candidateResult?.confidence === "low"
&& adaptiveRound < 3
? adaptiveRound + 1
@@ -1,8 +1,8 @@
export const evidenceDomains = [
export const legacyGuidedEvidenceDomains = [
"education", "relocation", "relationship", "career", "finance", "health_pressure",
] as const;
export type EvidenceDomain = (typeof evidenceDomains)[number];
export type LegacyGuidedEvidenceDomain = (typeof legacyGuidedEvidenceDomains)[number];
export type EvidenceQuestionPhase = "baseline" | "adaptive";
export type EvidenceDatePrecision = "day" | "month" | "year";
@@ -22,15 +22,15 @@ export type CandidateVargaSample = {
export type QuestionPlannerInput = {
readonly phase: EvidenceQuestionPhase;
readonly samples: readonly CandidateVargaSample[];
readonly askedDomains: readonly EvidenceDomain[];
readonly coveredDomains: readonly EvidenceDomain[];
readonly askedDomains: readonly LegacyGuidedEvidenceDomain[];
readonly coveredDomains: readonly LegacyGuidedEvidenceDomain[];
readonly adaptiveRound: number;
};
export type QuestionSpec = {
readonly questionId: string;
readonly phase: EvidenceQuestionPhase;
readonly domain: EvidenceDomain;
readonly domain: LegacyGuidedEvidenceDomain;
readonly requestedPrecision: readonly EvidenceDatePrecision[];
readonly allowUnknown: true;
readonly purposeCode: string;
@@ -47,7 +47,7 @@ const layerByDomain = {
} as const;
function questionSpecFor(
domain: EvidenceDomain,
domain: LegacyGuidedEvidenceDomain,
phase: EvidenceQuestionPhase,
adaptiveRound: number,
): QuestionSpec {
@@ -63,7 +63,7 @@ function questionSpecFor(
}
export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null {
const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain)
const available = legacyGuidedEvidenceDomains.filter((domain) => !input.askedDomains.includes(domain)
&& (domain !== "finance" || (input.phase === "adaptive" && input.coveredDomains.length >= 2)));
const ranked = available.map((domain) => ({
domain,
@@ -71,7 +71,7 @@ export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec
coverageBonus: input.coveredDomains.includes(domain) ? 0 : 1,
})).sort((left, right) => right.split - left.split
|| right.coverageBonus - left.coverageBonus
|| evidenceDomains.indexOf(left.domain) - evidenceDomains.indexOf(right.domain));
|| legacyGuidedEvidenceDomains.indexOf(left.domain) - legacyGuidedEvidenceDomains.indexOf(right.domain));
const winner = ranked[0];
return winner ? questionSpecFor(winner.domain, input.phase, input.adaptiveRound) : null;
}
@@ -10,11 +10,11 @@ import type {
BirthTimeJourneyPorts,
LegacyStoredRectificationCase as StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { LegacyCandidateResult } from "./birth-time-evidence.ts";
function requireMatchingEvidenceCounts(
stored: StoredRectificationCase,
candidate: CandidateResult,
candidate: LegacyCandidateResult,
): void {
const events = stored.lifeEvents ?? [];
const domainCount = new Set(events.map((event) => event.domain)).size;
@@ -80,7 +80,7 @@ export function createBirthTimeScoringService(ports: BirthTimeJourneyPorts) {
if (claim.algorithmVersion !== birthTimeScoringAlgorithmVersion) {
throw new BirthTimeScoringJobError("algorithm_mismatch");
}
let candidateResult: CandidateResult;
let candidateResult: LegacyCandidateResult;
try {
candidateResult = await ports.engine.scoreEvents(requireEventContext(stored));
if (candidateResult.algorithmVersion !== claim.algorithmVersion) {
+1 -6
View File
@@ -1,6 +1,7 @@
import { resolveAyanamsa } from "./ayanamsa.ts";
import { birthCalendarDate } from "./effective-birth-date.ts";
import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts";
import { recordOrEmpty as record } from "@/lib/guards";
export const CHART_BIRTH_FAILURE_REASONS = [
"profile_query_error",
@@ -50,12 +51,6 @@ const ADOPTION_CLEAR_KEYS = [
"timezone_offset",
] as const;
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
+1 -6
View File
@@ -1,6 +1,7 @@
import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts";
import { parseProfileGender } from "./profile-gender.ts";
import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts";
import { asRecord as recordOf } from "@/lib/guards";
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const DATE = /^(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
@@ -13,12 +14,6 @@ export type ChartSubjectWrite = {
readonly profile: Record<string, unknown>;
};
function recordOf(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown, max: number): string {
return typeof value === "string" ? value.trim().slice(0, max) : "";
}
+1 -6
View File
@@ -39,6 +39,7 @@ import {
type ChartViewWesternOk,
type NorthIndianChart,
} from "./chart-view-contract.ts";
import { asRecord as record } from "@/lib/guards";
export type ChartViewProfileInput = {
name?: string;
@@ -64,12 +65,6 @@ export type ChartViewEngineBundle = {
asOf: string;
};
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -2,6 +2,7 @@ import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import { assembleJudgmentTable, judgmentTableModelView, participationModelView } from "./consultation-judgment-table.ts";
import type { ProfileGender } from "./profile-gender.ts";
import { recordOrEmpty as record } from "@/lib/guards";
/**
* The evidence card: what the answer-writing model reads about the chart
@@ -379,10 +380,6 @@ export type EvidenceCardSource = Readonly<{
context: unknown;
}>;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isJson(value: unknown): value is Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
@@ -1,3 +1,4 @@
import { recordOrEmpty as record } from "@/lib/guards";
/**
* One judgment table per consult domain (TASK-consult-card-full-source-20261007).
*
@@ -34,10 +35,6 @@ const MALEFIC_BODIES = ["Saturn", "Mars", "Rahu", "Ketu"] as const;
const ASPECT_MALEFICS = ["Saturn", "Mars"] as const;
const DUSTHANA = new Set([6, 8, 12]);
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isJson(value: unknown): value is Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
@@ -20,6 +20,7 @@ import {
type ConsultationSubjectBinding,
type OwnedChartProfile,
} from "./consultation-subject-resolver.ts";
import { asRecord as record } from "@/lib/guards";
export type ConsultationProfileTruthErrorCode =
| "profile_unavailable"
@@ -213,12 +214,6 @@ export function declaredBirthAccuracyFromProfile(input: {
return { declaredAccuracy: "15min", timeSource: "family_vague" };
}
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function requiredText(profile: RecordValue, key: string): string {
const value = profile[key];
if (typeof value !== "string" || !value.trim()) {
+1 -6
View File
@@ -1,3 +1,4 @@
import { asRecord } from "@/lib/guards";
/**
* Per-step clocks for one ordinary consultation.
*
@@ -49,12 +50,6 @@ function tokenOrNull(value: unknown): number | null {
return truncated > 1_000_000_000 ? null : truncated;
}
function asRecord(value: unknown): UsageRecord | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UsageRecord
: null;
}
/** Read the provider's cached-input count. Absent means null, including when only a sibling field exists. */
export function cachedInputTokensFromUsage(usage: UsageRecord): number | null {
if ("cachedInputTokens" in usage) return tokenOrNull(usage.cachedInputTokens);
@@ -11,6 +11,7 @@ import {
ConsultationSubjectError,
resolveSubjectBirth,
} from "./subject-birth.ts";
import { asRecord as recordOf } from "@/lib/guards";
export {
ConsultationSubjectError,
@@ -58,12 +59,6 @@ function textOrNull(value: unknown): string | null {
return trimmed ? trimmed : null;
}
function recordOf(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function pickText(row: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const value = textOrNull(row[key]);
@@ -0,0 +1,38 @@
import { z } from "zod";
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const reportRequestThemes = z.enum([
"career",
"marriage",
"wealth",
"health",
"timing",
"general",
]);
/** Same strings as the schema default for `themes`. */
export const DEFAULT_REPORT_THEMES = [
"career",
"marriage",
"wealth",
"timing",
"health",
] as const;
export const personalReportCreateRequestSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid().nullable().optional(),
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(REPORT_DEPTHS).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default([...DEFAULT_REPORT_THEMES]),
}).strict();
/** Parsed server request. Defaulted fields are present. */
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
/** Client payload. Defaulted fields stay optional so callers can omit them. */
export type PersonalReportCreateRequestInput = z.input<typeof personalReportCreateRequestSchema>;
@@ -0,0 +1,78 @@
export const RECTIFICATION_CASE_STATUSES = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
"confirmed",
"closed",
"abandoned",
"superseded",
] as const;
export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number];
/** Statuses that may be resumed by the user. */
export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
];
/** Statuses that only allow read-only history access. */
export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"confirmed",
"closed",
"abandoned",
"superseded",
];
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
/** Wire shape. Status stays a string so cached and older payloads still parse. */
export type OpenRectificationCaseResponseWire = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
}>;
export type OpenRectificationCaseResponse = Omit<OpenRectificationCaseResponseWire, "status"> & {
readonly status: RectificationCaseStatus;
};
type LatestResumableWire = Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
}>;
type LatestTerminalWire = Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
}>;
export type RectificationEntrySummaryWire = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: LatestResumableWire | null;
latestTerminal: LatestTerminalWire | null;
}>;
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: (Omit<LatestResumableWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
latestTerminal: (Omit<LatestTerminalWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
}>;
+1 -4
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { effectiveBirthDate } from "./effective-birth-date";
import { asRecord } from "@/lib/guards";
export type DailyStarlanguageCard = {
readonly trend: string;
@@ -53,10 +54,6 @@ function normalizeLine(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 90);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
+1 -6
View File
@@ -7,6 +7,7 @@ import type {
} from "@/lib/ephemeris-contract";
import { isAyanamsaName, type AyanamsaName } from "@/lib/ayanamsa";
import { normalizePanchangaName, panchangaNameZh, splitTithiName } from "@/lib/panchanga-names-zh";
import { asRecord } from "@/lib/guards";
export const EPHEMERIS_COPY = {
title: "星历",
@@ -96,12 +97,6 @@ const FACTOR_LABEL: Record<EphemerisPanchangaFactor["key"], string> = {
karana: "迦那",
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+1 -6
View File
@@ -1,3 +1,4 @@
import { asRecord as record } from "@/lib/guards";
type RecordValue = Record<string, unknown>;
export type GeneralDailyReference = Readonly<{
@@ -34,12 +35,6 @@ type LoadGeneralDailyPanchangaInput = Readonly<{
apiBase?: string;
}>;
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Shared guards for non-array objects and finite numbers.
* Array-accepting record variants and string-to-number parsing stay private.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
export function recordOrEmpty(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function finiteNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* SHA-256 hex digest.
* Imports node:crypto. Server modules only — do not import this file from client components.
*/
import { createHash } from "node:crypto";
export function sha256Hex(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
+3 -2
View File
@@ -15,6 +15,9 @@
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
import { REPORT_DEPTHS, type ReportDepth } from "./contracts/personal-report-request";
export { REPORT_DEPTHS, type ReportDepth };
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
@@ -39,8 +42,6 @@ export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PRESENTATION_MODES = ["default", "research"] as const;
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
+4 -16
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { assembleReportFactTables } from "./report-fact-tables";
import { sha256Hex } from "./hash";
import {
computeEvidenceHash,
safeParseServerReportDocument,
@@ -10,13 +10,13 @@ import {
CHART_IDS,
REPORT_DOCUMENT_V2_ACTION_NOTES_MAX,
REQUIRED_THEME_CHARTS,
type ClaimStatus,
type EvidenceAppendix,
type ReportDepth,
type ReportDocumentParseError,
type ReportDocumentV1,
type ReportDocumentV2,
} from "./personal-report-contract.ts";
import type { ClaimStatus } from "./personal-report-contract.ts";
import {
PersonalReportAgentOutputError,
type EvidenceRefStatus,
@@ -62,8 +62,10 @@ import {
} from "./personal-report-plan.ts";
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts";
import { withReportBirthFingerprint } from "./report-birth-binding.ts";
import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
export { sha256Hex };
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
/**
@@ -105,10 +107,6 @@ export function canonicalSerialize(value: unknown): string {
return JSON.stringify(value);
}
export function sha256Hex(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
const sha256Pattern = /^[0-9a-f]{64}$/;
/**
@@ -183,22 +181,12 @@ export function resolveSkillSnapshot(): SkillSnapshot {
type JsonRecord = Record<string, unknown>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function booleanValue(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { sha256Hex } from "./hash";
export const LONGFORM_APPENDIX_TABLE = "personal_report_longform_appendices";
export const LONGFORM_APPENDIX_MAX_ATTEMPTS = 2;
@@ -25,7 +25,7 @@ export type LongformAppendixWrite = Readonly<{
}>;
export function hashLongformMarkdown(markdown: string): string {
return createHash("sha256").update(markdown, "utf8").digest("hex");
return sha256Hex(markdown);
}
export function parseLongformAppendixRow(value: unknown): LongformAppendixRow | null {
@@ -6,6 +6,7 @@ import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot
import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-english-edition";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { annotateUnreliableChartSections } from "./personal-report-chart-caveats";
import { asRecord as record } from "@/lib/guards";
import {
LONGFORM_APPENDIX_TABLE,
@@ -70,12 +71,6 @@ export type LongformGenerateDeps = Readonly<{
signal?: AbortSignal;
}>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
+2 -2
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { ReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import { REPORT_DEPTHS, type ReportDepth } from "./contracts/personal-report-request.ts";
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export { REPORT_DEPTHS, type ReportDepth };
export const REPORT_SECTION_KINDS = [
"executive_summary",
+6 -24
View File
@@ -13,8 +13,12 @@ import { parseReportCandidateRange, type ReportCandidateClockRange } from "./rep
* deletes, and the persistence service scopes every query by userId.
*/
import { z } from "zod";
import { effectiveBirthDate } from "./effective-birth-date";
import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards";
import {
personalReportCreateRequestSchema,
type PersonalReportCreateRequest,
} from "./contracts/personal-report-request";
import type { ConsultationInput } from "@/mastra";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import type {
@@ -49,19 +53,7 @@ import type {
PersonalReportService,
} from "./personal-report-service-core";
const reportRequestThemes = z.enum(["career", "marriage", "wealth", "health", "timing", "general"]);
export const personalReportCreateRequestSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid().nullable().optional(),
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing", "health"]),
}).strict();
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
export { personalReportCreateRequestSchema, type PersonalReportCreateRequest };
export type ReportRouteResponse = Readonly<{
status: number;
@@ -81,22 +73,12 @@ export type ReportServicePort = Pick<
type JsonRecord = Record<string, unknown>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const CONCRETE_REPORTED_SOURCES = new Set(["hospital_record", "family_exact", "approximate"]);
export type ReportSubjectBirthTimeStatus = "reported" | "candidate" | "accepted" | "confirmed";
+1 -11
View File
@@ -1,6 +1,7 @@
import type { ReportCandidateClockRange } from "./report-candidate-range";
import "server-only";
import { effectiveBirthDate } from "./effective-birth-date";
import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
@@ -38,7 +39,6 @@ import { resolveAyanamsa } from "@/lib/ayanamsa";
import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "@/lib/personal-report-worker-subject";
import { readReportBirthFingerprint } from "@/lib/report-birth-binding";
type JsonRecord = Record<string, unknown>;
type WorkerGlobal = typeof globalThis & {
jyotishaPersonalReportWorker?: PersonalReportWorkerHandle;
};
@@ -49,22 +49,12 @@ export type PersonalReportWorkerHandle = Readonly<{
stop: () => void;
}>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null {
const date = text(value);
const match = date ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) : null;
@@ -1,4 +1,5 @@
import { parseSegmentSummary, TARGET_CHARTS, type SegmentSummary, type TargetChart } from "./rectification-agentic/core/segment-summary.ts";
import { asRecord as record } from "@/lib/guards";
/**
* Which divisional charts a segment-v1 adoption could not determine (BUG-1138/1139).
@@ -28,10 +29,6 @@ export type AdoptedChartTierWarn = (payload: Readonly<{ event: "adopted_chart_ti
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
/** Only the segment-v1 adoption contract carries a result whose summary describes the saved minute. */
export function segmentProvenance(value: unknown): SegmentProvenance | null {
const row = record(value);
@@ -2,6 +2,7 @@ import { candidateSetId } from "./candidate-set-id.ts";
import { readCandidatePosition, parseWindowIntervals, type CandidatePosition } from "./candidate-window.ts";
import { INFERENCE_ALGORITHM_VERSION, type InferenceState } from "./types.ts";
import { buildSegmentSummary, parseAscendantSignature, parseSegmentSummary, TARGET_CHARTS, type SegmentMinute } from "./segment-summary.ts";
import { isRecord } from "@/lib/guards";
export type InferenceTransitionSnapshot = Readonly<{
id?: string;
@@ -39,10 +40,6 @@ const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]);
const ROUND_KINDS = new Set(["informative", "low_information", "tie_break"]);
const SCORE_DIRECTIONS = new Set(["support", "weak_support", "neutral", "weak_conflict", "conflict"]);
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isClockRange(value: unknown, dated = false): value is readonly [string, string] {
return Array.isArray(value)
&& value.length === 2
@@ -78,7 +78,8 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema } from "./choice-card";
import type { ChoiceKey } from "./choice-card";
import { vargaStyleFollowupRenderable } from "./probe-question-contract";
import { clusterScoreDeltas, excludedClusterRanges } from "./probe-explain.ts";
import {
@@ -17,10 +17,10 @@ import {
CHOICE_MODE,
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
type ChoiceKey,
type RectificationChoiceFrame,
type RectificationChoiceOption,
} from "./choice-card.ts";
import type { ChoiceKey } from "./choice-card.ts";
import {
MAX_SUB_BLOCK_ROUNDS,
SUB_BLOCK_LABELS,
@@ -6,39 +6,19 @@
* ordering.
*/
export const RECTIFICATION_CASE_STATUSES = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
"confirmed",
"closed",
"abandoned",
"superseded",
] as const;
import {
RECTIFICATION_CASE_STATUSES,
RESUMABLE_CASE_STATUSES,
TERMINAL_CASE_STATUSES,
} from "@/lib/contracts/rectification-entry";
import type { RectificationCaseStatus } from "@/lib/contracts/rectification-entry";
export type RectificationCaseStatus =
(typeof RECTIFICATION_CASE_STATUSES)[number];
/** Statuses that may be resumed by the user. */
export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
];
/** Statuses that only allow read-only history access. */
export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"confirmed",
"closed",
"abandoned",
"superseded",
];
export {
RECTIFICATION_CASE_STATUSES,
RESUMABLE_CASE_STATUSES,
TERMINAL_CASE_STATUSES,
};
export type { RectificationCaseStatus };
const RESUMABLE_SET = new Set<string>(RESUMABLE_CASE_STATUSES);
const TERMINAL_SET = new Set<string>(TERMINAL_CASE_STATUSES);
@@ -14,9 +14,9 @@ import {
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_SKIP_QUESTION_MESSAGE,
HOLDOUT_MESSAGE_PREFIX,
type ChoiceKey,
type RectificationChoiceCard,
} from "./choice-card";
import type { ChoiceKey } from "./choice-card";
import {
explainExcludedRanges,
explainRangeChange,
@@ -18,6 +18,9 @@ import {
type ProbeAnswerImpact,
type ProbeExplainCandidate,
} from "./probe-explain.ts";
import type { ChoiceKey } from "./probe-explain.ts";
export type { ChoiceKey };
export type ChoiceCardKind = EventProbeChoiceKind | "block_choice" | "widen_window";
@@ -53,8 +56,6 @@ export function isTieBreakRoundSchema(value: unknown): boolean {
);
}
export type ChoiceKey = "A" | "B" | "C" | "D";
export type RectificationChoiceOption = Readonly<{
key: ChoiceKey;
label: string;
@@ -12,6 +12,7 @@ import type { WindowInterval } from "../core/candidate-window.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../core/rectification-decision.ts";
import { indistinguishableWidthMinutes } from "./candidate-plateau.ts";
import { asRecord } from "@/lib/guards";
/**
* The six fields of `references/rectification_sealed_holdout.v1.json` the gate
* reads. That JSON stays the one source: `rectification-confirmation-gate.test.ts`
@@ -182,12 +183,6 @@ export function sessionOutcomeFromGate(input: {
return sessionOutcomeView("collect_evidence");
}
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
export function readVedastroMinuteSensitiveStatus(
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
): string {
@@ -29,6 +29,7 @@ import type { AscendantSignature } from "../core/segment-summary.ts";
import { isDatedScoringAlgorithmVersion, requestCandidateIntervals, readCandidatePosition, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts";
import { recordEngineCallTiming, reportTurnProgress } from "./turn-instrumentation.ts";
import { asRecord as record } from "@/lib/guards";
export type EngineCallFailureKind = "busy" | "http_error" | "timeout" | "bad_payload";
@@ -210,12 +211,6 @@ const EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2";
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function engineNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
@@ -23,13 +23,14 @@ import type { TransitionSignLookup } from "../core/sign-from-transitions.ts";
import { datedPrecision } from "./evidence-model.ts";
import {
isHoldoutVerificationQuote,
type ChoiceKey,
} from "./choice-card.ts";
import type { ChoiceKey } from "./choice-card.ts";
import {
parseNakshatraBoundary,
type DiscriminatingEventProbe,
type NakshatraBoundary,
} from "./refinement-packet.ts";
import { asRecord } from "@/lib/guards";
function yearFrom(value: string | null | undefined): number | null {
if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null;
@@ -470,12 +471,6 @@ export function buildCaseInferenceState(input: {
});
}
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { sha256Hex } from "@/lib/hash";
export const RECTIFICATION_MESSAGE_ORIGINS = [
"typed",
@@ -24,5 +24,5 @@ export function defaultMessageOrigin(action: string): RectificationMessageOrigin
}
export function messageContentHash(text: string | null | undefined): string {
return createHash("sha256").update(text ?? "").digest("hex");
return sha256Hex(text ?? "");
}
@@ -10,8 +10,18 @@
import { z } from "zod";
import {
isRectificationCaseStatus,
type RectificationCaseStatus,
} from "./case-status";
import type {
OpenRectificationCaseResponse,
OpenRectificationDisposition,
RectificationEntrySummary,
} from "@/lib/contracts/rectification-entry";
export type {
OpenRectificationCaseResponse,
OpenRectificationDisposition,
RectificationEntrySummary,
};
export const OPEN_RECTIFICATION_INTENTS = [
"homepage",
@@ -57,35 +67,7 @@ export const openRectificationDispositions = [
"created",
"resumed",
"readonly",
] as const;
export type OpenRectificationDisposition =
(typeof openRectificationDispositions)[number];
export type OpenRectificationCaseResponse = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: RectificationCaseStatus;
shouldStartOpening: boolean;
skillVersion: string;
}>;
/** Entry-summary contract used by the homepage card. */
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: Readonly<{
caseId: string;
status: RectificationCaseStatus;
lastActivityAt: string;
}> | null;
latestTerminal: Readonly<{
caseId: string;
status: RectificationCaseStatus;
hasUsableTime: boolean;
}> | null;
}>;
] as const satisfies readonly OpenRectificationDisposition[];
export function parseOpenRectificationCaseRequest(
value: unknown,
@@ -11,6 +11,7 @@ import { parseCandidateIntervals } from "../core/candidate-window.ts";
import { hasDatedTransition, parseDatedTransition, validDatedTransitions, type DatedTransitionPosition } from "../core/sign-from-transitions.ts";
import { engineMeaningToDisplayCopy } from "../user-copy.ts";
import { questionContractVersionIsCompatible } from "./probe-question-contract.ts";
import { asRecord } from "@/lib/guards";
const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -356,12 +357,6 @@ export type CandidateContrastOpportunity = Readonly<{
phase: "candidate_discriminator";
}>;
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asTime(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.slice(0, 5);
@@ -1,8 +1,5 @@
import { parseAscendantSignature, type SegmentMinute, type AscendantSignature } from "../core/segment-summary.ts";
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
import { asRecord as record } from "@/lib/guards";
export function segmentCandidateFields(row: Record<string, unknown> | null): { signature?: AscendantSignature; rawScore?: number } {
const signature = parseAscendantSignature(row?.signature);
@@ -3,7 +3,8 @@ import { z } from "zod";
import type { ResolvedLanguageModel } from "@/mastra/model";
import type { AnswerClass } from "../core/types";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import { parseAgentChoiceCopy } from "./choice-card";
import type { ChoiceKey } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
import type { ConversationFocus } from "./tool-service";
@@ -1,7 +1,8 @@
import { evidenceRecapLine } from "./host-fallback.ts";
import { displayDateLabel, eventPhraseFromSummary } from "./evidence-model.ts";
import { stripQuestionSentences } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey, type RectificationChoiceCard } from "./choice-card";
import { parseAgentChoiceCopy, type RectificationChoiceCard } from "./choice-card";
import type { ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify";
@@ -10,6 +10,7 @@ import {
parseWindowScanTransitions,
type WindowScanTransition,
} from "./refinement-packet";
import { asRecord } from "@/lib/guards";
export type WindowScan = Readonly<{
scanned: boolean;
@@ -48,12 +49,6 @@ export type InternalVargaObservation = Readonly<{
ask_theme: "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | null;
}>;
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asCount(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
@@ -9,9 +9,9 @@ import {
CHOICE_MODE,
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
type ChoiceKey,
type RectificationChoiceFrame,
} from "./choice-card.ts";
import type { ChoiceKey } from "./choice-card.ts";
import { WIDEN_WINDOW_KIND, WIDEN_WINDOW_INTENT } from "./block-scan.ts";
import {
MAX_DECLARED_RADIUS_MINUTES,
@@ -36,6 +36,7 @@ import {
verificationMarkdownFromUnknown,
type RangeDeliveryProjection,
} from "./rectification-agentic/v9/divergence-panel.ts";
import { finiteNumberOrNull as finiteNumber } from "@/lib/guards";
import { parseProductDelivery } from "./rectification-agentic/v9/product-delivery.ts";
@@ -134,10 +135,6 @@ function text(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const deniedHouseTable = /lon|latitude|longitude|degree|score|fingerprint/i;
@@ -22,7 +22,8 @@ import {
shouldContinueAfterStructuredChoice,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import { isPersistedFocusId, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
+14 -41
View File
@@ -5,54 +5,27 @@
* never guesses "resume vs create" from session lists or message counts.
*/
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
}> | null;
latestTerminal: Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
}> | null;
}>;
import {
RESUMABLE_CASE_STATUSES,
TERMINAL_CASE_STATUSES,
type OpenRectificationCaseResponseWire,
type OpenRectificationDisposition,
type RectificationEntrySummaryWire,
} from "@/lib/contracts/rectification-entry";
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
export type { OpenRectificationDisposition };
export type RectificationEntrySummary = RectificationEntrySummaryWire;
export type OpenRectificationCaseResponse = OpenRectificationCaseResponseWire;
export type OpenRectificationCaseResponse = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
}>;
const RESUMABLE_STATUSES = new Set([
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
]);
const TERMINAL_STATUSES = new Set([
"confirmed",
"closed",
"abandoned",
"superseded",
]);
const resumableStatuses = new Set<string>(RESUMABLE_CASE_STATUSES);
const terminalStatuses = new Set<string>(TERMINAL_CASE_STATUSES);
export function isResumableRectificationStatus(status: string): boolean {
return RESUMABLE_STATUSES.has(status);
return resumableStatuses.has(status);
}
export function isTerminalRectificationStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
return terminalStatuses.has(status);
}
/* `GET /api/rectification/cases/entry-summary` answers with the camelCase
+1 -4
View File
@@ -1,4 +1,5 @@
import "server-only";
import { asRecord as record } from "@/lib/guards";
import { resolveSessionLanguageModel } from "./model-catalog";
import { buildReplyQualitySnapshot } from "./reply-quality-snapshot";
@@ -15,10 +16,6 @@ export type ReplyQualityService = {
rpc(name: string, args: Record<string, unknown>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export type ReplyQualityCaptureResult = "saved" | "skipped" | "failed";
/**
+1 -6
View File
@@ -1,4 +1,5 @@
import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts";
import { asRecord as record } from "@/lib/guards";
/**
* Report ↔ birth-data binding for the report cover (TASK-birth-sky-followup-20260928,
@@ -19,12 +20,6 @@ export const REPORT_BIRTH_FINGERPRINT_KEY = "report_birth_fingerprint";
const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
/** Same algorithm and same input (the stored row) as the chart page's `profileFingerprint`. */
export function subjectBirthFingerprint(storedRow: unknown): string {
return resolveServerOwnedChartBirth(storedRow).fingerprint;
@@ -1,14 +1,9 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
import type { ClaimStatus } from "./personal-report-contract";
export type ClaimStatus =
| "multi_system_consensus"
| "single_system_inference"
| "parameter_sensitive"
| "unclosed_divisional_chart"
| "user_history_verification_required"
| "blocked";
export type { ClaimStatus };
export type EvidenceRefStatus = "verified" | "partial" | "blocked";
+1 -1
View File
@@ -1,7 +1,7 @@
import { findForbiddenContent } from "./personal-report-contract";
import { PLANET_ZH, SIGN_ORDER, SIGN_ZH, type PlanetName, type SignName } from "./chart-view-labels";
import { recordOrEmpty as record } from "@/lib/guards";
export const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
export const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value);
export const entries = (value: unknown) => Object.entries(record(value)).filter(([key]) => /^[A-Za-z0-9_-]+$/.test(key));
export const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
+2 -1
View File
@@ -1,7 +1,8 @@
import { reportFactTablesSchema, type ReportFactTable, type ReportFactSubtable } from "./report-fact-table-schema";
import { SIGN_ORDER, SIGN_ZH } from "./chart-view-labels";
import { containsHan, type ReportLanguage } from "./report-language";
import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable, record, sign, signEn, yesNo } from "./report-fact-table-values";
import { recordOrEmpty as record } from "./guards";
import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable, sign, signEn, yesNo } from "./report-fact-table-values";
const SOURCES = [
["vimshottari", "Vimshottari 主运", "timing_and_predictive_systems.dasha", "dasha", ["主运", "起", "止", "年数", "当前"]],
+1 -3
View File
@@ -9,6 +9,7 @@
*/
import type { ReportLanguage } from "./report-language";
import { isRecord } from "@/lib/guards";
export const REPORT_DOCUMENT_KINDS = [
"chat_export",
@@ -995,6 +996,3 @@ function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+1 -4
View File
@@ -20,6 +20,7 @@ import {
win32,
} from "node:path";
import { tmpdir } from "node:os";
import { isRecord } from "@/lib/guards";
export type SkillPackageStatus = "active" | "deprecated" | "blocked";
@@ -84,10 +85,6 @@ function fail(message: string): never {
throw new SkillPackageRegistryError(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(
value: unknown,
field: keyof SkillPackageIdentity,
+1 -6
View File
@@ -13,6 +13,7 @@ import {
globalBirthProfileFromAccountRow,
type AccountBirthRow,
} from "./server-owned-birth-profile.ts";
import { asRecord as recordOf } from "@/lib/guards";
export const consultationSubjectErrorCodes = [
"subject_not_found",
@@ -73,12 +74,6 @@ export function subjectIdFromQuery(value: unknown): string {
return trimmed;
}
function recordOf(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string {
return typeof value === "string" ? value.trim().slice(0, 80) : "";
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from "node:crypto";
import { randomBytes } from "node:crypto";
import { sha256Hex } from "../hash";
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -7,7 +8,7 @@ export function normalizeRedeemCode(code: string) {
}
export function hashRedeemCode(code: string) {
return createHash("sha256").update(code, "utf8").digest("hex");
return sha256Hex(code);
}
export function generateRedeemCode() {
+1 -4
View File
@@ -3,6 +3,7 @@ import { consultationEvidenceCategoryValues, createConsultationPlan, type Consul
import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts";
import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts";
import { applyUnreliableVargasToWorkflowContext } from "../lib/consultation-birth-time-mode.ts";
import { recordOrEmpty as record } from "@/lib/guards";
export const consultationInputSchema = z.object({
year: z.number().int().min(1900).max(2100),
@@ -58,10 +59,6 @@ export const consultationWorkflowResponseSchema = z.object({
}).passthrough().optional(),
}).passthrough();
function record(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
}
function rectificationBoundaryFromGate(rectification: JsonRecord): string {
const accuracy = String(rectification.effective_accuracy || "");
const lagnaSensitive = record(rectification.lagna_boundary).is_sensitive === true;
@@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { completeDynamicScoreTransition } from "../src/lib/birth-time-dynamic-transitions.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
const narrowedLowCandidate: CandidateResult = {
const narrowedLowCandidate: LegacyCandidateResult = {
resultId: "615499c9-f4da-4da0-a8bd-da26b2b8477f",
confidence: "low",
canApply: false,
@@ -4,7 +4,7 @@ import test from "node:test";
import {
completeDynamicScoreTransition,
} from "../src/lib/birth-time-dynamic-transitions.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { ServerChoiceEvidence } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import {
@@ -15,7 +15,7 @@ import {
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
const lowCandidate: CandidateResult = {
const lowCandidate: LegacyCandidateResult = {
resultId: "11111111-1111-4111-8111-111111111111",
confidence: "low",
canApply: false,
@@ -33,7 +33,7 @@ const lowCandidate: CandidateResult = {
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
function freshDynamicCase(
candidateResult: CandidateResult | null = null,
candidateResult: LegacyCandidateResult | null = null,
priorEvidence: readonly ServerChoiceEvidence[] = [],
) {
const stored = dynamicCase();
@@ -67,8 +67,8 @@ function freshDynamicCase(
}
function scoringFlow(input: {
readonly candidate?: CandidateResult;
readonly initialCandidate?: CandidateResult | null;
readonly candidate?: LegacyCandidateResult;
readonly initialCandidate?: LegacyCandidateResult | null;
readonly priorEvidence?: readonly ServerChoiceEvidence[];
readonly failOnce?: boolean;
readonly emptyNarrowedRange?: boolean;
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideDynamicStop } from "../src/lib/birth-time-dynamic-stop-policy.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
const lowCandidate: CandidateResult = {
const lowCandidate: LegacyCandidateResult = {
resultId: "11111111-1111-4111-8111-111111111111",
confidence: "low",
canApply: false,
@@ -18,7 +18,7 @@ const lowCandidate: CandidateResult = {
algorithmVersion: "birth-time-choice-scoring-v2",
};
const mediumCandidate: CandidateResult = {
const mediumCandidate: LegacyCandidateResult = {
...lowCandidate,
resultId: "22222222-2222-4222-8222-222222222222",
confidence: "medium",
@@ -8,7 +8,7 @@ import {
parseGuideQuestionOutput,
renderQuestionVariant,
} from "../src/lib/birth-time-guide-agent.ts";
import { evidenceDomains, type QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
import { legacyGuidedEvidenceDomains, type QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
import { draftEvidenceStructureTool, getBirthTimeGuideAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
@@ -174,14 +174,14 @@ test("server renders every approved variant from its QuestionSpec domain and pre
finance: /收入|资产|负债|资源渠道/,
health_pressure: /健康压力|生活压力/,
} as const;
for (const domain of evidenceDomains) {
for (const domain of legacyGuidedEvidenceDomains) {
for (const variant of guideQuestionVariants) {
const copy = renderQuestionVariant(question(domain), variant);
assert.equal((copy.match(/[??]/g) ?? []).length, 1);
assert.ok(copy.length <= 120);
assert.match(copy, semanticMarkers[domain]);
assert.doesNotMatch(copy, /哪一天|几号|具体日期|候选|支持|更符合|更接近|出生分钟/);
for (const otherDomain of evidenceDomains.filter((item) => item !== domain)) {
for (const otherDomain of legacyGuidedEvidenceDomains.filter((item) => item !== domain)) {
assert.doesNotMatch(copy, semanticMarkers[otherDomain]);
}
}
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { candidateResultSchema } from "../src/lib/birth-time-journey.ts";
import { completeScoreTransition } from "../src/lib/birth-time-journey-score-transition.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyStoredRectificationCase as StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import {
existingCareerEvent,
@@ -13,7 +13,7 @@ import {
const scoreJobId = "c70ea014-f8b4-41f2-9305-e4ae60c0d4d1";
function candidate(
confidence: CandidateResult["confidence"],
confidence: LegacyCandidateResult["confidence"],
resultId: string,
) {
const winningSegment = confidence === "high"
@@ -5,7 +5,7 @@ import type {
LegacyBirthTimeJourneyEngine,
LegacyStoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts";
import type { LegacyGuidedEvidenceDomain } from "../src/lib/birth-time-question-planner.ts";
import {
journeyCaseId,
memoryStore,
@@ -83,7 +83,7 @@ export function preserveLegacyResumeStore(store: BirthTimeJourneyStore): BirthTi
export function evidenceQuestion(
phase: "baseline" | "adaptive",
domain: EvidenceDomain,
domain: LegacyGuidedEvidenceDomain,
round: number,
) {
return {
@@ -129,9 +129,9 @@ export const lowCandidate = candidateResultSchema.parse({
type GuidedCaseInput = {
readonly version?: number;
readonly phase?: "baseline" | "adaptive";
readonly domain?: EvidenceDomain;
readonly domain?: LegacyGuidedEvidenceDomain;
readonly adaptiveRound?: number;
readonly askedDomains?: readonly EvidenceDomain[];
readonly askedDomains?: readonly LegacyGuidedEvidenceDomain[];
readonly lifeEvents?: readonly ReturnType<typeof lifeEventSchema.parse>[];
readonly candidateResult?: ReturnType<typeof candidateResultSchema.parse> | null;
};
@@ -5,7 +5,7 @@ import {
deriveNextAction,
projectJourneyTurn,
} from "../src/lib/birth-time-journey-turn.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
function question(domain: QuestionSpec["domain"]): QuestionSpec {
@@ -20,7 +20,7 @@ function question(domain: QuestionSpec["domain"]): QuestionSpec {
};
}
const lowResult: CandidateResult = {
const lowResult: LegacyCandidateResult = {
resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992",
confidence: "low",
canApply: false,
@@ -3,7 +3,7 @@ import {
scoringJobDurationMs,
scoringProcessingLeaseMs,
} from "../src/lib/birth-time-scoring-job.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import type {
BirthTimeJourneyStore,
StoredRectificationCase,
@@ -19,7 +19,7 @@ type MemoryJob = {
readonly expiresAt: string;
readonly updatedAt: string;
readonly status: "pending" | "processing" | "completed" | "failed";
readonly result: CandidateResult | null;
readonly result: LegacyCandidateResult | null;
};
type MemoryContext = {
@@ -1,4 +1,4 @@
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import type { BirthTimeJourneyStore } from "../src/lib/birth-time-journey-service.ts";
import {
@@ -15,7 +15,7 @@ import {
export const scoringTestUserId = "user-1";
export async function pendingScoringFlow(input: {
readonly score: () => Promise<CandidateResult>;
readonly score: () => Promise<LegacyCandidateResult>;
readonly now?: () => Date;
readonly transformStore?: (store: BirthTimeJourneyStore) => BirthTimeJourneyStore;
}) {
@@ -360,7 +360,10 @@ test("validates and emits non-sensitive workflow and execution receipts", () =>
test("consultationWorkflowResponseSchema still parses without western_evidence_packet", () => {
const schema = workflow.slice(
workflow.indexOf("export const consultationWorkflowResponseSchema"),
workflow.indexOf("function record("),
// 原值: function record(
// 新值: function rectificationBoundaryFromGate(
// 原因: 非数组否则空对象的 record 已改为 import recordOrEmpty,切片终点改到紧随其后的既有函数。
workflow.indexOf("function rectificationBoundaryFromGate("),
);
assert.match(schema, /success: z\.boolean\(\)/);
assert.match(schema, /chart: z\.record\(z\.unknown\(\)\)/);
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import type { PersonalReportCreateRequestInput } from "../src/lib/contracts/personal-report-request.ts";
import { asRecord, finiteNumberOrNull, isRecord, recordOrEmpty } from "../src/lib/guards.ts";
import { sha256Hex } from "../src/lib/hash.ts";
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix.ts";
import { hashRedeemCode } from "../src/lib/supabase/codes.ts";
const samples = [null, undefined, 0, "", [], {}, new Date()] as const;
function legacyIsRecord(value: unknown): boolean {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function legacyIsRecordBoolean(value: unknown): boolean {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function legacyAsRecordTruthy(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function legacyAsRecordNull(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function legacyRecordOrEmptyTruthy(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function legacyRecordOrEmptyNull(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function legacyFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
test("guards match the replaced non-array helpers on the shared sample table", () => {
for (const sample of samples) {
assert.equal(isRecord(sample), legacyIsRecord(sample));
assert.equal(isRecord(sample), legacyIsRecordBoolean(sample));
assert.equal(asRecord(sample), legacyAsRecordTruthy(sample));
assert.equal(asRecord(sample), legacyAsRecordNull(sample));
const emptyTruthy = legacyRecordOrEmptyTruthy(sample);
const emptyNull = legacyRecordOrEmptyNull(sample);
const empty = recordOrEmpty(sample);
if (isRecord(sample)) {
assert.equal(empty, sample);
assert.equal(emptyTruthy, sample);
assert.equal(emptyNull, sample);
} else {
assert.deepEqual(empty, {});
assert.deepEqual(emptyTruthy, {});
assert.deepEqual(emptyNull, {});
}
assert.equal(finiteNumberOrNull(sample), legacyFiniteNumber(sample));
}
assert.equal(finiteNumberOrNull(0), 0);
assert.equal(isRecord([]), false);
assert.equal(asRecord([]), null);
assert.deepEqual(recordOrEmpty([]), {});
});
test("sha256 helpers match createHash for ascii, chinese, and emoji", () => {
for (const sample of ["ascii", "中文", "😀"]) {
const direct = createHash("sha256").update(sample).digest("hex");
assert.equal(sha256Hex(sample), direct);
assert.equal(hashLongformMarkdown(sample), direct);
assert.equal(hashRedeemCode(sample), direct);
}
});
test("contracts and guards do not import server modules", () => {
const forbidden = /node:|server-only|@\/mastra|@\/lib\/supabase|@\/lib\/db/;
const root = fileURLToPath(new URL("../src/lib/", import.meta.url));
const files = [
join(root, "guards.ts"),
...readdirSync(join(root, "contracts")).map((name) => join(root, "contracts", name)),
];
for (const file of files) {
assert.equal(forbidden.test(readFileSync(file, "utf8")), false, file);
}
});
const okReportThemes: PersonalReportCreateRequestInput["themes"] = ["career"];
void okReportThemes;
// @ts-expect-error bogus is not a report request theme
const bogusReportThemes: PersonalReportCreateRequestInput["themes"] = ["bogus"];
void bogusReportThemes;
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { parseRectificationScoring } from "../src/lib/birth-time-journey-adapters.ts";
import { projectJourneyTurn } from "../src/lib/birth-time-journey-turn.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts";
const snapshot = {
state: "rectifying",
@@ -15,7 +15,7 @@ const snapshot = {
reportedRange: { label: "10:00—11:00", startTime: "10:00", endTime: "11:00" },
} as const;
const lowResult: CandidateResult = {
const lowResult: LegacyCandidateResult = {
resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992",
confidence: "low",
canApply: false,
+74 -24
View File
@@ -288,6 +288,55 @@ def _calc_candidate_balance_and_birth_chain(moon_longitude: float, starting_plan
return balance, birth_chain
def _timeline_start_with_birth_balance(
birth_dt: datetime,
moon_longitude,
starting_planet: str,
) -> tuple[datetime, bool]:
"""Shift the first mahadasha so birth falls inside the elapsed portion.
Both applicability families use the starting lord's nakshatra group. When
the Moon longitude is missing, or it does not fall in that group, the
timeline stays on the birth instant.
"""
if moon_longitude is None:
return birth_dt, False
balance, _birth_chain = _calc_candidate_balance_and_birth_chain(
moon_longitude=float(moon_longitude),
starting_planet=starting_planet,
)
if balance is None:
return birth_dt, False
md_years = next(lord["years"] for lord in DASHA_SEQUENCE if lord["planet"] == starting_planet)
elapsed_years = md_years - float(balance["years"])
if elapsed_years == 0:
return birth_dt, False
return birth_dt - timedelta(days=elapsed_years * 365.25), True
def _current_period_from_shifted_majors(major_periods: list, now: datetime) -> dict | None:
if not major_periods:
return None
first = datetime.fromisoformat(major_periods[0]["start_date"])
last = datetime.fromisoformat(major_periods[-1]["end_date"])
span = last - first
if span.total_seconds() <= 0 or now < first:
return None
cycles = int((now - first).total_seconds() // span.total_seconds())
for period in major_periods:
start = datetime.fromisoformat(period["start_date"]) + cycles * span
end = datetime.fromisoformat(period["end_date"]) + cycles * span
if start <= now < end:
current = period.copy()
current["start_date"] = start.isoformat()
current["end_date"] = end.isoformat()
current["elapsed_years"] = (now - start).days / 365.25
current["remaining_years"] = (end - now).days / 365.25
current["cycle_number"] = cycles + 1
return current
return None
def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list:
"""
Build the full list of major periods (Mahadashas) given the starting lord.
@@ -418,36 +467,37 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict:
idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet
)
major_periods = _build_major_periods(start_lord_idx, birth_dt)
timeline_start, balance_shifted = _timeline_start_with_birth_balance(
birth_dt, moon_longitude, starting_planet,
)
major_periods = _build_major_periods(start_lord_idx, timeline_start)
antardasha_periods, pratyantardasha_periods = _attach_recursive_subperiods(major_periods)
balance_at_birth = None
dasha_at_birth = None
if moon_longitude is not None and rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
balance_at_birth, dasha_at_birth = _calc_candidate_balance_and_birth_chain(
moon_longitude=float(moon_longitude),
starting_planet=starting_planet,
)
# Determine current period. Major periods repeat every 108 years; older natives
# should still return a current period instead of None after the first cycle.
# A shifted timeline is read from the period dates. An unshifted timeline keeps
# the previous birth-anchored walk so charts without a Moon longitude stay put.
now = datetime.now()
current_period = None
age_years = max((now - birth_dt).days / 365.25, 0)
current_in_cycle = age_years % TOTAL_CYCLE
cumulative = 0.0
for p in major_periods:
years = p["years"]
if cumulative <= current_in_cycle < cumulative + years:
cycle_start = birth_dt + timedelta(days=(age_years - current_in_cycle + cumulative) * 365.25)
cycle_end = cycle_start + timedelta(days=years * 365.25)
current_period = p.copy()
current_period["start_date"] = cycle_start.isoformat()
current_period["end_date"] = cycle_end.isoformat()
current_period["elapsed_years"] = (now - cycle_start).days / 365.25
current_period["remaining_years"] = (cycle_end - now).days / 365.25
current_period["cycle_number"] = int(age_years // TOTAL_CYCLE) + 1
break
cumulative += years
if balance_shifted:
current_period = _current_period_from_shifted_majors(major_periods, now)
else:
age_years = max((now - birth_dt).days / 365.25, 0)
current_in_cycle = age_years % TOTAL_CYCLE
cumulative = 0.0
for p in major_periods:
years = p["years"]
if cumulative <= current_in_cycle < cumulative + years:
cycle_start = birth_dt + timedelta(days=(age_years - current_in_cycle + cumulative) * 365.25)
cycle_end = cycle_start + timedelta(days=years * 365.25)
current_period = p.copy()
current_period["start_date"] = cycle_start.isoformat()
current_period["end_date"] = cycle_end.isoformat()
current_period["elapsed_years"] = (now - cycle_start).days / 365.25
current_period["remaining_years"] = (cycle_end - now).days / 365.25
current_period["cycle_number"] = int(age_years // TOTAL_CYCLE) + 1
break
cumulative += years
result = {
"applicable": True,
+8 -5
View File
@@ -12,6 +12,11 @@ from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from scripts.dasha_common import nakshatra_seeded_birth_balance
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
from dasha_common import nakshatra_seeded_birth_balance
YEAR_DAYS = 365.25636
NAKSHATRA_SPAN = 360.0 / 27.0
@@ -65,11 +70,9 @@ def _sequence_from(lord: str) -> list[tuple[str, int]]:
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
moon = float(moon_longitude) % 360.0
nakshatra = int(moon // NAKSHATRA_SPAN)
lord = DASHA_SEQUENCE[((nakshatra - SWATI_INDEX) % 27) % len(DASHA_SEQUENCE)][0]
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
return lord, balance, nakshatra
return nakshatra_seeded_birth_balance(
moon_longitude, anchor_index=SWATI_INDEX, sequence=DASHA_SEQUENCE,
)
def calculate_chaturshitisama_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
额外Dasha系统模块 v1.0
基于BPHS条件性Dasha体系
支持的Dasha系统:
1. Dwisaptati Sama Dasha (72年周期) — 当Lagna lord在特定条件时触发
2. Shattrimsa Sama Dasha (36年周期) — 当Moon在特定条件时触发
3. Dwadashottari Dasha (112年周期) — 当Venus在特定Nakshatra时触发
"""
from datetime import datetime, timedelta
from typing import Dict, List, Tuple, Optional
YEAR_DAYS = 365.25636
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
NAKSHATRAS = ['Ashwini','Bharani','Krittika','Rohini','Mrigashira','Ardra',
'Punarvasu','Pushya','Ashlesha','Magha','Purva Phalguni','Uttara Phalguni',
'Hasta','Chitra','Swati','Vishakha','Anuradha','Jyeshtha',
'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha',
'Purva Bhadrapada','Uttara Bhadrapada','Revati']
VIMSHOTTARI_DURATION = {'Ketu':7,'Venus':20,'Sun':6,'Moon':10,'Mars':7,'Rahu':18,'Jupiter':16,'Saturn':19,'Mercury':17}
DASHA_ORDER = ['Ketu','Venus','Sun','Moon','Mars','Rahu','Jupiter','Saturn','Mercury']
# =============================================================================
# Dwisaptati Sama Dasha (72年周期)
# 适用条件:Lagna lord在Lagna或7宫时激活
# 主星序列:特定8星顺序,每9年一个主星
# =============================================================================
DWISAPTATI_LORDS = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu']
DWISAPTATI_DURATION = {l: 9 for l in DWISAPTATI_LORDS} # 每个9年,共72年
DWISAPTATI_SUB_DURATION = {p: VIMSHOTTARI_DURATION[p]*9/120 for p in DWISAPTATI_LORDS}
def check_dwisaptati_condition(lagna_lord: str, lagna_lord_house: int) -> bool:
"""检查Dwisaptati触发条件:Lagna lord在1或7宫"""
return lagna_lord_house in (1, 7)
def calc_dwisaptati_dasha(birth_date: datetime, lagna_lord: str,
lagna_lord_degree: float) -> List[Dict]:
"""
计算Dwisaptati Sama Dasha(72年周期)。
每个主星周期 = 9年,子周期按Vimshottari比例分配。
"""
start_idx = DWISAPTATI_LORDS.index(lagna_lord)
sequence = []
current = birth_date
for i in range(8):
lord_idx = (start_idx + i) % 8
lord = DWISAPTATI_LORDS[lord_idx]
days = 9 * YEAR_DAYS
end_date = current + timedelta(days=days)
# 子周期
subs = []
sub_start = current
for j in range(8):
sub_lord_idx = (lord_idx + j) % 8
sub_lord = DWISAPTATI_LORDS[sub_lord_idx]
sub_days = days * (VIMSHOTTARI_DURATION.get(sub_lord, 9) / 120)
sub_end = sub_start + timedelta(days=sub_days)
subs.append({'lord': sub_lord, 'start': sub_start.strftime('%Y-%m-%d'),
'end': sub_end.strftime('%Y-%m-%d')})
sub_start = sub_end
sequence.append({
'lord': lord, 'years': 9,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
'sub_periods': subs[:4],
})
current = end_date
return sequence
# =============================================================================
# Shattrimsa Sama Dasha (36年周期)
# 适用条件:特定条件触发(Moon-based condition)
# 主星序列:9星,每星4年
# =============================================================================
SHATTRIMSA_DURATION = {l: 4 for l in DASHA_ORDER}
def check_shattrimsa_condition(moon_sign: str) -> bool:
"""Shattrimsa触发条件:Moon在特定星座/条件时"""
return True # 简化:总可用
def calc_shattrimsa_dasha(birth_date: datetime, moon_nakshatra_idx: int) -> List[Dict]:
"""计算Shattrimsa Sama Dasha(36年周期)。每个主星=4年"""
start_lord_idx = moon_nakshatra_idx % 9
sequence = []
current = birth_date
for i in range(9):
lord_idx = (start_lord_idx + i) % 9
lord = DASHA_ORDER[lord_idx]
days = 4 * YEAR_DAYS
end_date = current + timedelta(days=days)
sequence.append({
'lord': lord, 'years': 4,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
})
current = end_date
return sequence
# =============================================================================
# Dwadashottari Dasha (112年周期)
# 适用条件:当Venus在特定Nakshatra(Revati/UttaraBhadrapada等)时触发
# 主星序列:特定8星,不同年限
# =============================================================================
DWADASHOTTARI_LORDS = ['Sun','Jupiter','Ketu','Mercury','Rahu','Mars','Saturn','Moon']
DWADASHOTTARI_YEARS = {'Sun':7,'Jupiter':9,'Ketu':11,'Mercury':13,'Rahu':15,'Mars':17,'Saturn':19,'Moon':21}
VENUS_TRIGGER_NAKSHATRAS = {24, 25, 26} # Shatabhisha, Purva Bhadra, Uttara Bhadra
def check_dwadashottari_condition(venus_nakshatra_idx: int) -> bool:
"""Dwadashottari触发条件:Venus在特定Nakshatra"""
return venus_nakshatra_idx in VENUS_TRIGGER_NAKSHATRAS
def calc_dwadashottari_dasha(birth_date: datetime, moon_degree: float) -> List[Dict]:
"""计算Dwadashottari Dasha(112年周期)"""
nak_idx = int(moon_degree / (360/27))
start_lord_idx = nak_idx % 8
sequence = []
current = birth_date
for i in range(8):
lord_idx = (start_lord_idx + i) % 8
lord = DWADASHOTTARI_LORDS[lord_idx]
years = DWADASHOTTARI_YEARS[lord]
days = years * YEAR_DAYS
end_date = current + timedelta(days=days)
sequence.append({
'lord': lord, 'years': years,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
})
current = end_date
return sequence
# =============================================================================
# 条件性Dasha综合选择器
# =============================================================================
def get_applicable_dashas(planets: Dict, lagna_sign: str, moon_degree: float) -> Dict:
"""
根据星盘条件推荐适用���Dasha系统。
Returns:
各Dasha系统的触发状态和计算结果
"""
results = {'vimshottari': {'applicable': True, 'note': '始终适用(120年标准周期)'}}
# Dwisaptati
lagna_lord = {'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon',
'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars',
'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter'}.get(lagna_sign, '')
lord_house = planets.get(lagna_lord, {}).get('house', 0)
if check_dwisaptati_condition(lagna_lord, lord_house):
results['dwisaptati'] = {'applicable': True, 'note': f'Lagna lord {lagna_lord}在{lord_house}宫——72年周期激活'}
# Shattrimsa
if check_shattrimsa_condition(lagna_sign):
results['shattrimsa'] = {'applicable': True, 'note': 'Moon-based condition——36年周期可用'}
# Dwadashottari
venus_data = planets.get('Venus', {})
venus_nak = int(venus_data.get('degree', 0) / (360/27))
if check_dwadashottari_condition(venus_nak):
results['dwadashottari'] = {'applicable': True, 'note': f'Venus在触发星宿——112年周期激活'}
return results
+4 -12
View File
@@ -6,18 +6,10 @@ Vedic Astrology Dasha Period Calculator
This script helps calculate Dasha periods based on Nakshatra position.
"""
# Standard Vimshottari Dasha periods in years
VIMSHOTTARI_PERIODS = {
'Ketu': 7,
'Venus': 20,
'Sun': 6,
'Moon': 10,
'Mars': 7,
'Rahu': 18,
'Jupiter': 16,
'Saturn': 19,
'Mercury': 17
}
try:
from dasha_common import VIMSHOTTARI_YEARS as VIMSHOTTARI_PERIODS
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.dasha_common import VIMSHOTTARI_YEARS as VIMSHOTTARI_PERIODS
# Planetary order in Vimshottari Dasha
DASHA_ORDER = ['Ketu', 'Venus', 'Sun', 'Moon', 'Mars', 'Rahu', 'Jupiter', 'Saturn', 'Mercury']
+4 -12
View File
@@ -21,18 +21,10 @@ import math
# 恒星年(天)- 基于jyotishganit (MIT License),用于精确Dasha计算
YEAR_DURATION_DAYS = 365.25636
# Vimshottari Dasha周期(年)- 总120年
VIMSHOTTARI_PERIODS = {
'Ketu': 7,
'Venus': 20,
'Sun': 6,
'Moon': 10,
'Mars': 7,
'Rahu': 18,
'Jupiter': 16,
'Saturn': 19,
'Mercury': 17
}
try:
from dasha_common import VIMSHOTTARI_YEARS as VIMSHOTTARI_PERIODS
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.dasha_common import VIMSHOTTARI_YEARS as VIMSHOTTARI_PERIODS
HUMAN_LIFE_SPAN_VIMSHOTTARI = 120.0

Some files were not shown because too many files have changed in this diff Show More