fix(consult): date the full-data cache and slim the model card

The cache key now includes the request date, and files older than two days are removed. The model card keeps values only. Parashari aspects are copied from the engine. Saving a chart warms the packet without blocking the response.

Biography severe conflicts rose from 1 to 3, so this is not accepted for merge. Not pushed.
This commit is contained in:
jesse-ux
2026-10-09 10:00:45 +08:00
parent 3874a328a5
commit 16f9f86843
20 changed files with 976 additions and 23 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-09 — 对话卡按提问当天取数,模型只看见数值(未推送,未合入,未上线)
- 同一张盘隔天再问,当前大运、行运和年盘按提问当天重算,不再冻在第一次计算的那天。超过两天的缓存文件会清掉。
- 送给模型的判断表只留数值和缺口。内部路径留在服务端。三张公开盘、十二个领域都不超过 2 万字符,最大 18,193。受冲仍只计数。
- 行星相位从资料库和引擎抄进判断表。罗睺、计都不向外投射相位。
- 新建或修改出生资料后,后台预热当天的资料包,不挡住保存。
- 系统提示没有改。Skill 版本不变。未推送,未合入,未上线。
## 2026-10-09 — 对话里每个问题带上一张和完整报告同一份资料的综合判断表(未上线)
- 对话里每个问题会带上一张和完整报告同一份资料的综合判断表。需要再看的资料,每次对话最多再取 3 次。
+80
View File
@@ -17375,3 +17375,83 @@
- 相关记录:BUG-1293。
- 复发自:无。
- 修复版本:本分支提交。未部署。
## BUG-1296 | 对话资料包缓存键没有参照日期
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F1。Claude 验收:缓存键不含日期,后一次命中把当前大运、行运和年盘冻在第一次计算的那天。
- 影响面:普通对话使用的完整资料包缓存。同一张盘隔天再问,可能仍用第一天的当前大运。
- 现象:`build_full_data_packet` 用运行时的当天日期填写 today、transit_date 和 target_year,缓存键里没有这个日期。
- 触发条件:同一出生资料在不同日期命中已有缓存文件。
- 根因:键只含出生资料、岁差、交点模式和引擎版本。schema 为 `consult-full-data-cache-v1`。
- 修复:schema 改为 `consult-full-data-cache-v2`。键加入参照日期:请求里的 today 或 transit_date,都没有则用服务端当天。当前大运、行运、年盘用这同一个日期。超过两天没写过的缓存文件会删掉。旧的 v1 文件不再当作命中。
- 验证:`tests/test_consult_card_full_source.py` 同一张盘两个日期键不同、两天以前的文件被删、当天的文件还在;资料包的 today、transit_date、target_year 是同一个日期。三张公开盘每个领域的当前大运主星与资料包里的 current_dasha.lord 一致。
- 防复发:上述测试。重复跑之前先删掉这两把自己的缓存文件,避免上次留下的文件被当成命中。
- 相关记录:BUG-1251。
- 复发自:无。
- 修复版本:本分支提交。未部署。
## BUG-1297 | 送给模型的判断表带上内部路径,整卡超过两万字符
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F2。Claude 验收:乔布斯事业卡由真实事实端到端组出后为 37,507 字符,判断表 27,159,参与清单 5,447。每格带有 status、path、check、value,内部路径进了模型。
- 影响面:普通对话送给模型的证据卡。服务端副本仍保留路径,供核对。
- 现象:模型看见的卡超过临时上限 20,000 字符,并含有 `worksheets.`、`core_chart.` 和 `"check"`。
- 触发条件:事实层接到证据卡之后,判断表和参与清单原样放进 claim_cards。
- 根因:模型视图没有去掉机器字段。长叶子超过 800 字符时会收成没有 value 的占位,这些占位也进了卡。
- 修复:模型视图只留数值。缺口、挡住、未给出数值的占位留一个简短状态,或整行不送。path、check 留在服务端组装结果里。同一颗星的后几行只留身份、角色、出处和受冲计数。宫位行已经写过的八分法,行星行不再重复。受冲仍只计数,不设门槛。数值不改写、不四舍五入、不翻译。
- 验证:三张公开盘十二个领域,用生平 golden 的工作流组卡。最大 18,193 字符(财富)。没有 `worksheets.`、`core_chart.`、`"check"`。`frontend/tests/consult-card-full-source-20261007.test.ts` 锁住乔布斯、奥巴马、泰勒这条路径和 20,000 上限。
- 防复发:该测试。手写事实的组装测试只锁组装规则,并在注释里写明它不代替金样。
- 相关记录:BUG-1160、BUG-1161、BUG-1221。临时上限仍是卡 20,000、卡加清单 26,000。
- 复发自:无。
- 修复版本:本分支提交。未部署。
## BUG-1298 | 十二个领域的行星相位整列记成 blocked
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F3。Claude 验收:参与层因为路由器没有把行星相位列成一层,十二个领域都是 blocked。
- 影响面:普通对话判断表的「照」。不新造罗睺、计都向外的相位。
- 现象:判断表上没有抄到引擎已经算出的 house_aspects。
- 触发条件:领域事实从资料包取「照」。
- 根因:出处在 `references/signs-and-houses.md` 第六节,引擎在 `calc_house_aspects`。资料包组装时没有把这份结果抄进 worksheets。
- 修复:在 `build_pl9_style_export_packet` 之后,把 `modules.aspects.house_aspects` 抄到 `worksheets.graha_drishti`。事实层按行星抄「被谁照」和「照哪些宫」。七颗行星为投射星;罗睺、计都只做目标。目录出处改为该节。缺这一层时写 gap,不写 blocked。
- 验证:三张公开盘十二个领域的 `parashari_aspects` 状态都是 value,出处以 `references/signs-and-houses.md` 开头,并且至少一颗星有照到的宫或被照的星名。
- 防复发:`tests/test_consult_card_full_source.py` 的公开盘测试。
- 相关记录:BUG-1155。
- 复发自:无。
- 修复版本:本分支提交。未部署。
## BUG-1299 | 对话卡合同测试用手工事实,不是引擎金样
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F4。合同测试的形状必须来自真实引擎。
- 影响面:`frontend/tests/consult-card-full-source-20261007.test.ts`。手写组装测试保留,只锁组装规则。
- 现象:测试自己拼 facts,体量上限量在一张空的 claim card 上。
- 触发条件:跑这组前端合同测试。
- 根因:当时没有一份冻结的领域事实金样。
- 修复:新增 `frontend/tests/fixtures/consult-card-facts-20261009.json`。生成命令(仓库根目录):`python scripts/research/freeze_consult_card_facts_fixture.py`。参照日 2026-09-27,岁差 raman,交点 mean。含三张公开盘和一张虚构盘,各十二个领域。乔布斯、奥巴马、泰勒的体量断言改接到生平 golden 工作流。
- 验证:该前端测试读金样并组出真实模型卡。断言改动写了原值、新值、原因。
- 防复发:测试直接读这份金样。重新生成用上面的命令,不手改数字。
- 相关记录:BUG-1297。
- 复发自:无。
- 修复版本:本分支提交。未部署。
## BUG-1300 | 新建或修改星盘后没有预热对话资料包
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F5。前序任务书要求星盘建好或出生资料改过之后,后台预热当天的资料包。
- 影响面:星盘新建、星盘修改、本人出生资料保存。保存响应不等待计算。
- 现象:第一次提问才开始算整包,用户等在对话里。
- 触发条件:保存成功,且日期、时间、经纬度、时区都有。缺任何一项则不预热,也不补一个时间。
- 根因:保存路由没有调用咨询计算。
- 修复:保存成功后用 `after()` 安排一次 `consult_card_warm_only` 请求。Python 在原有咨询入口里提前返回预热状态,不新增处理类方法,响应里不带资料包。改出生资料后键变了,旧文件不会被当成命中(BUG-1251)。参照日期也在键里。
- 验证:`frontend/tests/consult-packet-warm-20261009.test.ts` 检查请求体,并确认调用方不等待未完成的计算。`tests/test_consult_card_full_source.py` 确认预热响应没有资料包。`tests/test_api_server_growth_contract.py` 通过,类方法数没有增加。`jyotish_api_server.py` 10,963 行,粗护栏上限 11,224。
- 防复发:上述两个测试。lint 对该测试 0 error。
- 相关记录:BUG-1251、BUG-1296。
- 复发自:无。
- 修复版本:本分支提交。未部署。
@@ -0,0 +1,103 @@
# PROGRESS · 对话卡同源修复(2026-10-09)
分支 `codex/consult-card-full-source-20261007`。已 rebase 到 `origin/staging` `19bae8a8`,本地提交 `b216b286` 之上还有未推送的修复。状态:执行中。F6 未通过。未推送,未合入,未上线。
## 结论
F1 到 F5 已按修复单做完。F6 未通过,不让步:严重冲突从 1 增到 3,对照组误报仍是 0。卡体量最大 18,193 字符,没有内部路径。建议上限仍留在 20,000。未推送。
## 修复
| 项 | 做法 |
| --- | --- |
| F1 缓存日期 | 键加入参照日期(请求的 today 或 transit_date,否则服务端当天)。当前大运、行运、年盘用这同一个日期。schema `consult-full-data-cache-v2`。超过两天的文件删除。BUG-1296。 |
| F2 模型只看数值 | path、check 留在服务端。缺口留一个状态字。超过 800 字符、没有值的占位不送进模型。受冲仍只计数。BUG-1297。 |
| F3 行星相位 | 资料包抄上引擎的 house_aspects。出处 `references/signs-and-houses.md` 第六节。七颗行星投射,罗睺计都只做目标。BUG-1298。 |
| F4 金样 | `frontend/tests/fixtures/consult-card-facts-20261009.json`。命令:`python scripts/research/freeze_consult_card_facts_fixture.py`。参照日 2026-09-27,岁差 raman,交点 mean。BUG-1299。 |
| F5 预热 | 星盘新建、修改、本人资料保存成功后,`after()` 后台预热。缺日期、时间、经纬度或时区则跳过。不挡住保存。不新增 API 类方法。BUG-1300。 |
`scripts/jyotish_api_server.py` 10,963 行,粗护栏 11,224。类方法数没有增加。`page.tsx` 的 useState / useRef 没有增加。系统提示文件没有改。KP 仍不上卡。年运卡仍读 `tajika_yogas`。
## 卡片体量
组卡方式与合同测试相同:生平 golden 工作流加上冻结的领域事实。父母、婚恋、健康、事业用本领域路由;其余领域借用事业路由的本命底,体量略偏大。
| 盘 | 最大领域 | 字符 | 十二个领域是否都 ≤ 20,000 | 内部路径 |
| --- | --- | --- | --- | --- |
| 乔布斯 | 财富 16,921 | 最大 16,921 | 是 | 无 |
| 奥巴马 | 财富 16,313 | 最大 16,313 | 是 | 无 |
| 泰勒 | 财富 18,193 | 最大 18,193 | 是 | 无 |
没有 `worksheets.`、`core_chart.`、`"check"`。
上限建议:临时上限维持 20,000。最大实测 18,193,留约 1,800 字符。不降到 18,000,因为只量了三张公开盘。卡加清单的临时上限 26,000 仍够用:改后事业工具结果约 18,290 到 19,600 字符。
## 模型对比
模型 `deepseek-flash`。key 只在当次命令的环境变量里,没有写入仓库、日志正文或本文。
生平回测:9 位公开人物 × 父母 / 婚恋 / 健康 / 事业 × 2 次。改前不挂事实,用当时的 golden 卡。改后挂参照日 2026-09-27 的事实。引擎仍是 golden 桩。
| 项 | 改前 | 改后 |
| --- | --- | --- |
| 份数 | 72 | 72 |
| 失败 | 0 | 0 |
| 空答 | 0 | 0 |
| 墙钟 | 862 秒 | 921 秒 |
| 平均总耗时 | 46.2 秒 | 49.9 秒 |
| 平均输入 token | 27,984.9 | 28,655.8 |
| 其中缓存 token 平均 | 22,519.1 | 21,240.9 |
| 平均回答 token | 9,311.8 | 9,328.0 |
| 平均思考 token | 8,736.4 | 8,736.1 |
| 平均卡字符 | 6,918.7 | 14,129.8 |
| 最大卡字符 | 7,561 | 16,281 |
| 禁句 | 0 | 0 |
| 性别代词 | 0 | 0 |
| must_not 原文处数 | 7 | 4 |
must_not 是线索,不是阅读结论。改前 7 处、改后 4 处,都落在「可能不在身边,也可能人在」这种两头都写的句子上。按这个口径,两头都写不算对照组误报,也不算严重冲突。改前阅读:严重冲突 1,就是泰勒事业第 2 次把公开职业写成不靠频繁曝光;这一条同时记行业冲突。对照组误报 0。改后 18 份事业回答里没有「不靠曝光 / 幕后 / 不是台前 / 顾问 / 技术」。
改后有 1 份(乔布斯父母第 1 次)多调了一次家庭工具再回到父母。当时进程里的脚本还没放行家庭域。源码现已对家庭、子女返回空,不抛错。这一份留在 72 份的平均数里。
首字时间是另一次直连,不是上面的代理循环。同一套系统提示(本次读到 25,165 字符;本轮没有改提示词文件),用户问题相同,只换工具结果。流一开始就中止,记的是第一个思考 token。空答 0。
| 盘 | 改前首字 | 改后首字 | 改前工具结果 | 改后工具结果 |
| --- | --- | --- | --- | --- |
| 乔布斯 | 1,064 毫秒 | 769 毫秒 | 11,988 | 18,290 |
| 奥巴马 | 707 毫秒 | 485 毫秒 | 11,839 | 18,836 |
| 泰勒 | 465 毫秒 | 642 毫秒 | 11,776 | 19,600 |
| 平均 | 745 毫秒 | 632 毫秒 | | |
样本只有 3 题,第一发最慢。卡变大约一倍,首字没有变慢,72 份的平均总耗时多 3.7 秒。
## 阅读
同一口径:两头都写的「可能不在,也可能人在」不算误报,也不算严重冲突。直接把行业写成另一类,或把公开人物写成不靠曝光、往里走、不靠场面,记严重冲突,同时记行业冲突。
| 项 | 改前 | 改后 |
| --- | --- | --- |
| 通过 | 71 | 69 |
| 错过 | 0 | 0 |
| 读反 | 0 | 0 |
| 严重冲突 | 1 | 3 |
| 对照组误报 | 0 | 0 |
| 行业冲突 | 1 | 3 |
| 空话凑段 | 3 | 2 |
改前的 1 条是泰勒事业第 2 次。改后仍是泰勒事业第 2 次,另外齐达内事业两次都把公开的球星路子写成靠专业判断、把事情说清楚,或写成说得清、写得明、算得出,并压低外面认不认你这一头。红线不允许严重冲突升高。F6 停在这里,没有改评分,也没有改系统提示。
## 本机已跑
| 检查 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 |
| `npm run lint` | 0 error,125 条原有 warning |
| 增长合同 + 对话卡 pytest | 18 通过 |
| 隐私标记(已暂存当时的代码与金样) | 通过 |
| 前端合同测试(金样组卡、预热) | 通过 |
## 还没跑完 / 不能写成通过
- 快速门含 `npm test` 和 `next build`。跑完再记。Windows 上 `next build` 可能被工作树的 `node_modules` 联接挡住,那是环境缺口,不改业务代码去迎合。
- 没有在 Linux 上重跑全量 pytest。不把 Windows 的失败名单说成 Linux 那 62 条基线。
- 没有登录态。真机清单 `docs/testing/consult-card-full-source-fix-20261009.md` 结果列为空。
+1 -1
View File
@@ -130,7 +130,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-consult-card-full-source-20261007.md` | `PROGRESS-consult-card-full-source-20261007.md` | **对话数据卡与报告同源 + 领域综合判断表**:事业卡只有约 7,600 字符、无自然代表星、无大运主星关系、D10 联动只能补取、补取上限 1;全 12 领域;综合判断表、参与清单(不止 D1/D9,含精度闸)、补取 3 次、资料目录均已确认;KP 不上卡、不联网、不接书籍语料;排在 sync6 与报告单之后 | 验收未通过(`35dafd1e`,见 fix-20261009) | `codex/consult-card-full-source-20261007` |
| `TASK-consult-card-full-source-fix-20261009.md` | — | **对话卡同源修复**:缓存键无日期致当前大运冻结、整卡 37.5K(每格带内部路径)、星照整列 blocked(资料库其实有出处)、前端测试手写数据、无预热;合入前用临时 key 跑模型 A/B 与生平回测 | 待领取 | — |
| `TASK-consult-card-full-source-fix-20261009.md` | `PROGRESS-consult-card-full-source-fix-20261009.md` | **对话卡同源修复**:缓存键无日期致当前大运冻结、整卡 37.5K(每格带内部路径)、星照整列 blocked(资料库其实有出处)、前端测试手写数据、无预热;合入前用临时 key 跑模型 A/B 与生平回测 | 执行中(F6 严重冲突 1→3,未让步) | `codex/consult-card-full-source-20261007` |
| — (产品 09-27 拍板 D1–D4,直接执行) | [PROGRESS](PROGRESS-home-landing-blank-20260927.md) | **登录后 / 裸 `/` 落空白首页**:真机登录后在「首页」提问其实问进了上一次生时校正。删登录返回存根(401 / 次级页链接写、登录后写回 `?c=` 打开),裸 `/` 不再落最近会话,一律当前人物的空白首页(复用空草稿);`?c=` / `?new=1` / 对话内刷新不变;推翻 BUG-1038 存根与 BUG-599 默认落点 | 已验收(真机欠) | `codex/home-landing-blank-20260927`(BUG-1052,本地未推) |
| — (产品 09-26 口头拍板 D1–D3,直接执行) | `PROGRESS-consult-answer-truncation-20260926.md` | **普通咨询回答写到一半被掐断仍扣点(BUG-1051,复发自 BUG-305)**:工具循环与写回答共用 110 秒 signal;Mastra 1.50 超时不抛错(`abort` 块 + `finish(tripwire)` 后正常关流),结算只认抛错与 `length`。D1 写回答自有 70 秒时钟(首用起算,续写 / 回答重试共用,最坏 180 秒,`maxDuration` 240);D2 写回答的最后一个流不是 `stop` 且有正文 → `answer_truncated`、不扣点、记 abort 步、不冲半句,`length` 续写不变;D3 观测加 `composeFinishReason` / `composeAborted` / `answerVisibleChars` | 已验收(真机欠) | `codex/consult-answer-truncation-20260926`(本地,未推送);新回归 15 条用真实 Mastra `Agent`(修复前 11 条红);全量失败名单 0 新增;Python 948/1;`/` ○、gzip 0%;真机清单 `docs/testing/consult-answer-truncation-20260926.md` |
| `TASK-consult-evidence-card-research-20260927.md` | `PROGRESS-consult-evidence-card-research-20260927.md` | **普通对话数据卡调研**:引擎输出逐项分五类计量(现约 4 万 token、父母问题相关约 3.5%);四处领域→技法来源对账并起草各领域数据卡(家庭拆父母/子女);卡体量与逐字一致性;按卡算的提速空间;反馈迭代埋点方案。只调研不改线上 | 已验收(待产品拍板 7 项) | `codex/consult-evidence-card-research-20260927` 快进 staging;报告 `docs/research/consult_evidence_card_research_2026_09_27.md`;投影缺口记 BUG-1054 investigating |
@@ -0,0 +1,12 @@
# 真机清单:对话卡同源修复(2026-10-09)
对应 `docs/tasks/TASK-consult-card-full-source-fix-20261009.md`。部署到 staging 之后照做。本机没有登录态,结果列留空。问法仍用 `docs/testing/consult-card-full-source-20261007.md`。
准备:一个出生时间已确认到分钟的账号。先记下星盘页上的当前大运主星。
| # | 操作 | 应看到 | 结果 |
| --- | --- | --- | --- |
| 1 | 新建一张资料完整的星盘,或改一次出生时间后保存 | 保存马上返回。没有多出来的等待圈。 | |
| 2 | 新开普通对话,问事业 | 回答用到判断表里的因素。正文里不出现 `worksheets.`、`core_chart.` 这种内部路径。受冲只作为计数,不说「两个及以上所以怎样」。 | |
| 3 | 对照星盘页的当前大运主星 | 回答里的当前大运主星与星盘页一致。 | |
| 4 | 把出生时间改成另一个已确认的时间,再问同一个事业问题 | 回答用的是改过之后的盘,不沿用改之前那次的大运。 | |
@@ -170,13 +170,33 @@ const GOLDEN_ROUTE_FOR_DOMAIN: Record<string, string> = {
career: "career",
};
let factsFixture: { figures?: Record<string, { domains?: Record<string, Json> }> } | null = null;
/** Set only for the after run. The file is the server facts copy; the card slims it. */
function factsForFigure(figureId: string, domain: string): Json | null {
const fixturePath = process.env.CONSULT_CARD_FACTS_FIXTURE;
if (!fixturePath) return null;
if (!factsFixture) factsFixture = JSON.parse(readFileSync(fixturePath, "utf8"));
const facts = factsFixture?.figures?.[figureId]?.domains?.[domain];
if (!facts || typeof facts !== "object") {
// family/children reuse the parents golden route and are not in the four-domain fixture.
if (domain === "family" || domain === "children") return null;
throw new Error(`consult card facts missing for ${figureId} ${domain}`);
}
return facts;
}
function stubRunWorkflow(figure: GoldenFigure, executed: string[]) {
return async (input: { theme: string }) => {
const key = GOLDEN_ROUTE_FOR_DOMAIN[input.theme];
if (!key) throw new Error(`backtest golden has no engine route for domain ${input.theme}`);
executed.push(input.theme);
// runConsultationWorkflow: schema parse, then minute-sensitive themes on the policy.
const data = consultationWorkflowResponseSchema.parse(routeWorkflow(figure, key));
const parsed = consultationWorkflowResponseSchema.parse(routeWorkflow(figure, key));
const facts = factsForFigure(figure.id, input.theme);
const data = facts
? { ...parsed, consult_card_facts: { schema: "consult-card-facts-v1", domains: [facts] } }
: parsed;
const consumer = data.consumer_context as Json;
return {
...data,
+2
View File
@@ -9,6 +9,7 @@ import {
resolveAppliedAccountBirthTime,
} from "@/lib/account-profile-patch";
import { chartBirthAfterAccountWrite, resolveServerOwnedChartBirth } from "@/lib/chart-birth-truth";
import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import {
@@ -347,6 +348,7 @@ export async function PATCH(request: Request) {
if (error || !data) {
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
}
scheduleConsultPacketWarm({ ...withCoordinates, ...data });
return NextResponse.json({
ok: true,
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "@/lib/chart-subject-write";
import { CHART_SUBJECT_API_SELECT, isChartSubjectId } from "@/lib/subject-birth";
import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
@@ -149,6 +150,7 @@ export async function PUT(request: Request, context: RouteContext) {
throw error;
}
if (!data) return NextResponse.json({ error: "星盘不存在或无权更新" }, { status: 404 });
scheduleConsultPacketWarm(parsed.write.columns);
return NextResponse.json({ profile: toClientProfile(data as Record<string, unknown>) });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
@@ -5,6 +5,7 @@ import {
subjectLimitReached,
} from "@/lib/chart-subject-write";
import { CHART_SUBJECT_API_SELECT, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
@@ -109,6 +110,7 @@ export async function POST(request: Request) {
if (mapped) return mapped;
throw error;
}
scheduleConsultPacketWarm(parsed.write.columns);
return NextResponse.json({ profile: toClientProfile(data as Record<string, unknown>) });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
+65
View File
@@ -0,0 +1,65 @@
import { after } from "next/server";
/**
* After a chart is saved, ask the engine to cache today's full-data packet.
* The save response does not wait for that build (TASK fix F5).
*/
type WarmDeps = {
after: (task: () => Promise<void> | void) => void;
fetch: typeof fetch;
apiBase?: string;
};
function asNumber(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);
return null;
}
export function consultWarmBodyFromColumns(columns: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!columns) return null;
const date = typeof columns.birth_date === "string" ? columns.birth_date : "";
const time = typeof columns.reported_birth_time === "string" ? columns.reported_birth_time : "";
const lat = asNumber(columns.latitude);
const lon = asNumber(columns.longitude);
const tz = asNumber(columns.timezone_offset);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
const clock = /^(\d{1,2}):(\d{2})/.exec(time);
if (!match || !clock || lat === null || lon === null || tz === null) return null;
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(clock[1]),
minute: Number(clock[2]),
second: 0,
lat,
lon,
tz,
ayanamsa: typeof columns.ayanamsa === "string" && columns.ayanamsa ? columns.ayanamsa : "lahiri",
node_mode: "mean",
consult_card_warm_only: true,
};
}
export function scheduleConsultPacketWarm(
columns: Record<string, unknown> | null | undefined,
deps: WarmDeps = {
after,
fetch,
apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
},
): boolean {
const body = consultWarmBodyFromColumns(columns);
if (!body) return false;
const apiBase = deps.apiBase ?? "http://127.0.0.1:5200";
deps.after(() => {
return deps.fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).then(() => undefined).catch(() => undefined);
});
return true;
}
@@ -1,6 +1,6 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import { assembleJudgmentTable } from "./consultation-judgment-table.ts";
import { assembleJudgmentTable, judgmentTableModelView, participationModelView } from "./consultation-judgment-table.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
@@ -1076,8 +1076,8 @@ export function buildEvidenceCard(
if (own !== base) section.base = own;
const facts = domainFacts(source);
if (facts) {
section.judgment_table = assembleJudgmentTable(facts);
if (Array.isArray(facts.participation)) section.participation = structuredClone(facts.participation) as Json;
section.judgment_table = judgmentTableModelView(assembleJudgmentTable(facts)) as JsonRecord;
if (Array.isArray(facts.participation)) section.participation = participationModelView(facts.participation);
}
domainSections[source.domain] = section;
}
+131 -2
View File
@@ -145,8 +145,13 @@ function afflictionForHouse(number: number, planets: Record<string, unknown>, ho
return { count: details.length, details: details as Json[], unevaluated };
}
function numberList(value: unknown): number[] {
return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : [];
}
function relationsFor(planet: string, planets: Record<string, unknown>, houses: Record<string, unknown>): Json[] {
const house = leafValue(record(planets[planet]).d1_house);
const cast = new Set(numberList(leafValue(record(planets[planet]).aspects_houses)));
const rows: Json[] = [];
for (const [number, item] of Object.entries(houses)) {
const domainHouse = Number(number);
@@ -154,6 +159,7 @@ function relationsFor(planet: string, planets: Record<string, unknown>, houses:
const labels: string[] = [];
if (lord === planet) labels.push(JUDGMENT_TABLE_LABELS.relationLord);
if (typeof house === "number" && house === domainHouse) labels.push(JUDGMENT_TABLE_LABELS.relationOccupant);
if (cast.has(domainHouse)) labels.push(JUDGMENT_TABLE_LABELS.relationAspect);
if (typeof lord === "string" && lord !== planet && typeof house === "number") {
const lordHouse = leafValue(record(planets[lord]).d1_house);
if (lordHouse === house) labels.push(JUDGMENT_TABLE_LABELS.relationConjunctLord);
@@ -163,6 +169,10 @@ function relationsFor(planet: string, planets: Record<string, unknown>, houses:
return rows;
}
function aspectKnown(row: Record<string, unknown>): boolean {
return leaf(row.aspected_by).status === "value" || leaf(row.aspects_houses).status === "value";
}
function vargottama(planetRow: Record<string, unknown>): JsonRecord {
const d1 = leaf(planetRow.d1_sign);
const d9 = leaf(planetRow.d9_sign);
@@ -225,8 +235,10 @@ function planetColumns(
d9_dignity: leaf(row.d9_dignity),
vargottama: vargottama(row),
domain_varga_house: leaf(row.domain_varga_house),
aspected_by: leaf(row.aspected_by),
aspects_houses: leaf(row.aspects_houses),
relations: relationsFor(planet, planets, houses),
relation_gaps: [JUDGMENT_TABLE_LABELS.relationAspect],
relation_gaps: aspectKnown(row) ? [] : [JUDGMENT_TABLE_LABELS.relationAspect],
};
}
@@ -253,7 +265,8 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
occupants: leaf(house.occupants),
sav: savOf([item.house], houses)[0] ?? null,
affliction: afflictionForHouse(item.house, planets, houses),
relation_gaps: [JUDGMENT_TABLE_LABELS.relationAspect],
aspected_by: leaf(house.aspected_by),
relation_gaps: leaf(house.aspected_by).status === "value" ? [] : [JUDGMENT_TABLE_LABELS.relationAspect],
});
const lord = leafValue(house.lord);
if (typeof lord === "string") {
@@ -304,3 +317,119 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
rows,
};
}
const MACHINE_KEYS = new Set(["path", "check", "derived_from", "member", "index"]);
function isLeafRecord(value: Record<string, unknown>): boolean {
return typeof value.status === "string" && ("path" in value || "check" in value || "value" in value || value.status === "gap" || value.status === "blocked");
}
function filledRelations(value: Json | undefined): Json[] {
if (!Array.isArray(value)) return [];
return value.filter((item) => Array.isArray((item as JsonRecord | null)?.labels) && (item as JsonRecord).labels && ((item as JsonRecord).labels as Json[]).length > 0);
}
function housesWithSav(rows: Json[]): Set<number> {
const found = new Set<number>();
for (const item of rows) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const sav = (item as JsonRecord).sav;
if ((item as JsonRecord).kind !== "house" || !sav || typeof sav !== "object" || Array.isArray(sav)) continue;
const house = (sav as JsonRecord).house;
if (typeof house === "number") found.add(house);
}
return found;
}
function savAlreadyOnHouseRows(sav: Json | undefined, covered: Set<number>): boolean {
if (!Array.isArray(sav) || sav.length === 0) return false;
return sav.every((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const house = (item as JsonRecord).house;
return typeof house === "number" && covered.has(house);
});
}
/**
* The server table repeats one planet's columns on every factor row.
* The model keeps that planet's values once. A later row keeps the role
* and the affliction count, which can change with the role. SAV stays on
* the house row when that row already carries it.
*/
function compactPlanetRows(rows: Json[]): Json[] {
const covered = housesWithSav(rows);
const seen = new Set<string>();
return rows.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const row = { ...(item as JsonRecord) };
if (row.kind !== "planet" || typeof row.planet !== "string") return row;
const relations = filledRelations(row.relations as Json | undefined);
if (!seen.has(row.planet)) {
seen.add(row.planet);
if (savAlreadyOnHouseRows(row.sav as Json | undefined, covered)) delete row.sav;
if (relations.length) row.relations = relations;
else delete row.relations;
return row;
}
const repeat: JsonRecord = { id: row.id, kind: row.kind, planet: row.planet };
if (typeof row.role === "string") repeat.role = row.role;
if (typeof row.citation === "string") repeat.citation = row.citation;
if (row.affliction !== undefined) repeat.affliction = row.affliction;
return repeat;
});
}
/** Values for the model. Internal paths and the check field stay off this view. */
export function judgmentTableModelView(value: unknown): Json {
const slimmed = slimModel(value);
if (!slimmed || typeof slimmed !== "object" || Array.isArray(slimmed)) return slimmed;
const body = slimmed as JsonRecord;
if (!Array.isArray(body.rows)) return slimmed;
return { ...body, rows: compactPlanetRows(body.rows) };
}
export function participationModelView(value: unknown): Json {
if (!Array.isArray(value)) return [];
const rows: Json[] = [];
for (const item of value) {
const row = record(item);
const out: JsonRecord = {};
if (typeof row.id === "string") out.id = row.id;
if (row.status === "value" && "value" in row) {
const copied = copyJson(row.value);
out.value = copied === undefined ? null : copied;
if (typeof row.citation === "string") out.citation = row.citation;
} else if (row.status === "withheld" && typeof row.value === "string") {
out.value = row.value;
if (typeof row.citation === "string") out.citation = row.citation;
} else if (row.status === "gap" || row.status === "blocked") {
out.status = row.status;
} else {
continue;
}
rows.push(out);
}
return rows;
}
function slimModel(value: unknown): Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (Array.isArray(value)) return value.map((item) => slimModel(item));
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
if (isLeafRecord(row)) {
if ((row.status === "value" || row.status === "derived") && "value" in row) {
const copied = copyJson(row.value);
return copied === undefined ? null : slimModel(copied);
}
if (row.status === "withheld") return typeof row.value === "string" ? row.value : "withheld";
if (row.status === "gap" || row.status === "blocked" || row.status === "value") return row.status;
}
const out: JsonRecord = {};
for (const [key, item] of Object.entries(row)) {
if (MACHINE_KEYS.has(key)) continue;
out[key] = slimModel(item);
}
return out;
}
@@ -1,10 +1,28 @@
// Judgment table assembly (TASK-consult-card-full-source-20261007 T2).
// Leaves are copied. The count is not a verdict. Rahu and Ketu stay unjudged.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { assembleJudgmentTable, JUDGMENT_TABLE_LABELS } from "../src/lib/consultation-judgment-table.ts";
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
// Assertion change (F4 / BUG-1299):
// 原值: this file built `facts` by hand, and the size cap was measured on an empty claim card.
// 新值: chart bodies are read from consult-card-facts-20261009.json, frozen by
// `python scripts/research/freeze_consult_card_facts_fixture.py` (reference date 2026-09-27).
// Jobs, Obama and Taylor also attach those facts to the biography golden workflow.
// 原因: contract fixtures must come from the engine, and the 20,000-character cap is the card
// the model reads, including the natal base. The handwritten test only locks assembly.
const PRECISION = "出生时间精度不够,不参与";
@@ -128,4 +146,87 @@ test("a card without facts keeps the old section, and facts attach one table", (
const encoded = JSON.stringify(section);
assert.equal(encoded.includes("threshold"), false);
assert.equal(encoded.includes("两个及以上"), false);
// 原值: the card repeated path and check. 新值: those stay on the server facts.
// 原因: the model must not see internal field paths (F2).
assert.equal(encoded.includes("worksheets."), false);
assert.equal(encoded.includes("core_chart."), false);
assert.equal(encoded.includes("\"check\""), false);
});
const FIGURE_FOR_CHART: Record<string, string> = {
jobs: "steve_jobs",
obama: "barack_obama",
taylor: "elizabeth_taylor",
};
const biographyGolden = JSON.parse(readFileSync(join(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"consult-biography-backtest-golden.json",
), "utf8")) as { figures: Array<{ id: string; shared_chart: Record<string, unknown>; routes: Record<string, Record<string, unknown>> }> };
function biographyWorkflow(figureId: string, domain: string) {
const figure = biographyGolden.figures.find((item) => item.id === figureId);
if (!figure) throw new Error(`missing biography figure ${figureId}`);
const routeDomain = ["parents", "marriage", "health", "career"].includes(domain) ? domain : "career";
const route = figure.routes[routeDomain];
if (!route) throw new Error(`missing biography route ${figureId} ${routeDomain}`);
const { chart: chartOverrides, chart_modules: moduleOverrides, ...rest } = structuredClone(route);
const shared = structuredClone(figure.shared_chart);
const chart = {
...shared,
...((chartOverrides as Record<string, unknown> | undefined) ?? {}),
modules: {
...((shared.modules as Record<string, unknown> | undefined) ?? {}),
...((moduleOverrides as Record<string, unknown> | undefined) ?? {}),
},
};
return consultationWorkflowResponseSchema.parse({ ...rest, chart });
}
test("engine facts make a model card without internal paths", () => {
const golden = JSON.parse(readFileSync(join(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"consult-card-facts-20261009.json",
), "utf8")) as {
charts: Record<string, { domains: Record<string, { planets: Record<string, { functional_role?: { value?: unknown } }> }> }>;
};
for (const [chart, body] of Object.entries(golden.charts)) {
for (const [domain, domainFacts] of Object.entries(body.domains)) {
assert.equal(domainFacts.planets.Rahu?.functional_role?.value ?? null, null, chart);
assert.equal(domainFacts.planets.Ketu?.functional_role?.value ?? null, null, chart);
const factsContext = { consult_card_facts: { schema: "consult-card-facts-v1", domains: [domainFacts] } };
const figureId = FIGURE_FOR_CHART[chart];
const card = figureId
? (() => {
const parsed = biographyWorkflow(figureId, domain);
const context = toAgentConsultationContext({ ...parsed, ...factsContext });
const packet = toModelOutput(
context,
createConsultationPlan({ userIntent: "q", theme: domain as ConsultationDomain }),
domain,
);
return buildEvidenceCard([{ domain: domain as ConsultationDomain, packet, context }]);
})()
: buildEvidenceCard([{
domain: domain as ConsultationDomain,
packet: { claim_cards: [] },
context: factsContext,
}]);
const encoded = JSON.stringify(card);
assert.equal(encoded.includes("worksheets."), false, `${chart} ${domain}`);
assert.equal(encoded.includes("core_chart."), false, `${chart} ${domain}`);
assert.equal(encoded.includes("\"check\""), false, `${chart} ${domain}`);
assert.equal(encoded.includes("两个及以上"), false);
assert.ok(encoded.length <= 20_000, `${chart} ${domain} ${encoded.length}`);
const rows = (card.domain_sections[domain as ConsultationDomain]?.judgment_table?.rows ?? []) as Array<Record<string, unknown>>;
const hasAspect = rows.some((row) => (
(Array.isArray(row.aspected_by) && row.aspected_by.length > 0)
|| (Array.isArray(row.aspects_houses) && row.aspects_houses.length > 0)
|| JSON.stringify(row.relations ?? "").includes("照")
));
assert.equal(hasAspect, true, `${chart} ${domain}`);
}
}
});
@@ -0,0 +1,35 @@
// Warmup is scheduled after the save returns. It does not wait for the engine.
import assert from "node:assert/strict";
import test from "node:test";
import { consultWarmBodyFromColumns, scheduleConsultPacketWarm } from "../src/lib/consult-packet-warm.ts";
const columns = {
birth_date: "1901-01-01",
reported_birth_time: "12:30",
latitude: 1,
longitude: 2,
timezone_offset: 8,
ayanamsa: "raman",
};
test("a saved chart becomes a warm request and the caller does not wait", () => {
const body = consultWarmBodyFromColumns(columns);
assert.equal(body?.consult_card_warm_only, true);
assert.equal(body?.minute, 30);
assert.equal(consultWarmBodyFromColumns({ birth_date: "1901-01-01" }), null);
let started = false;
const finished = false;
const pending = new Promise<Response>(() => {});
const scheduled = scheduleConsultPacketWarm(columns, {
after: (task) => { void task(); },
fetch: () => {
started = true;
return pending;
},
apiBase: "http://127.0.0.1:9",
});
assert.equal(scheduled, true);
assert.equal(started, true);
assert.equal(finished, false);
});
File diff suppressed because one or more lines are too long
+57 -3
View File
@@ -227,6 +227,58 @@ def _d9(view: dict, name: str) -> tuple[dict[str, Any], dict[str, Any]]:
return sign, dignity
def _house_aspect_rows(view: dict) -> list[dict] | None:
rows = dig(view, "worksheets.graha_drishti.house_aspects")
if not isinstance(rows, list):
return None
return [row for row in rows if isinstance(row, dict)]
def _aspect_targets(row: dict) -> list[int]:
targets = row.get("aspects_to")
if not isinstance(targets, dict):
return []
numbers = []
for key in targets:
try:
numbers.append(int(key))
except (TypeError, ValueError):
continue
return numbers
def _aspected_by(view: dict, house: Any) -> dict[str, Any]:
path = "worksheets.graha_drishti.house_aspects"
rows = _house_aspect_rows(view)
if rows is None or not isinstance(house, int):
return _gap(path)
names = []
for row in rows:
planet = row.get("planet")
if isinstance(planet, str) and house in _aspect_targets(row) and planet not in names:
names.append(planet)
return {"status": "value", "path": path, "value": names, "check": "selection"}
def _aspects_houses(view: dict, name: str) -> dict[str, Any]:
path = "worksheets.graha_drishti.house_aspects"
rows = _house_aspect_rows(view)
if rows is None:
return _gap(path)
for row in rows:
if row.get("planet") == name:
return {"status": "value", "path": path, "value": _aspect_targets(row), "check": "selection"}
return {"status": "value", "path": path, "value": [], "check": "selection"}
def _parashari_aspects(view: dict) -> dict[str, Any]:
citation = "references/signs-and-houses.md §六 行星相位"
leaf = _leaf(view, "worksheets.graha_drishti.house_aspects")
if leaf["status"] != "value":
return {"id": "parashari_aspects", "status": "gap", "citation": citation, "check": "path", "path": leaf.get("path")}
return _sourced("parashari_aspects", citation, leaf)
def _co_tenants(view: dict, name: str, house: Any) -> dict[str, Any]:
if not isinstance(house, int):
return {"status": "gap", "check": "selection"}
@@ -254,7 +306,8 @@ def _planet_record(view: dict, name: str, varga: str | None, minute_ok: bool) ->
"d1_dignity": _leaf(view, f"{base}.status"),
"rasi_lord": _leaf(view, f"{base}.rasi_lord"),
"combust": _leaf(view, f"{base}.combust"),
"aspected_by": _leaf(view, f"{base}.aspected_by"),
"aspected_by": _aspected_by(view, house),
"aspects_houses": _aspects_houses(view, name),
"functional_role": _functional_role(view, name),
"shadbala_rupas": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.total_rupas"),
"shadbala_min": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.min_required"),
@@ -287,6 +340,7 @@ def _house_record(view: dict, number: int) -> dict[str, Any]:
f"worksheets.strengths_and_scores.shadbala.bhava_bala.{number}",
]),
"occupants": {"status": "value", "check": "selection", "value": occupants},
"aspected_by": _aspected_by(view, number),
}
@@ -366,7 +420,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
("vimsopaka", "sourced", f"{DEEP} Vimsopaka"),
("ashtakavarga", "sourced", f"{ROUTER} §2 Ashtakavarga"),
("functional", "sourced", f"{ROUTER} §2 Functional benefic/malefic"),
("parashari_aspects", "blocked", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
("parashari_aspects", "sourced", "references/signs-and-houses.md §六 行星相位"),
("jaimini_aspects", "sourced", f"{JAIMINI} Jaimini Drishti"),
("argala", "sourced", "references/argala-complete-guide.md"),
("dispositor_chain", "sourced", f"{ROUTER} §2 dispositor chain"),
@@ -529,7 +583,7 @@ def _shared_participation(view: dict, minute_ok: bool) -> list[dict[str, Any]]:
_sourced("vimsopaka", f"{DEEP} Vimsopaka", _leaf(view, "worksheets.strengths_and_scores.vimsopaka")),
_sourced("ashtakavarga", f"{ROUTER} §2 Ashtakavarga", _leaf(view, "worksheets.strengths_and_scores.ashtakavarga")),
_sourced("functional", f"{ROUTER} §2 Functional benefic/malefic", _leaf(view, "worksheets.strengths_and_scores.functional_benefic_malefic")),
_blocked("parashari_aspects", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
_parashari_aspects(view),
_sourced("jaimini_aspects", f"{JAIMINI} Jaimini Drishti", _first(view, [
"worksheets.advanced_systems.jaimini.rashi_aspects",
"worksheets.advanced_systems.jaimini.drishti",
+80 -10
View File
@@ -1,7 +1,9 @@
"""Cache the full-data report packet for the consult card.
The key is chart identity + ayanamsa + node mode + engine version.
The key is chart identity + ayanamsa + node mode + engine version
+ the reference date used for current dasha, transits and the year chart.
The file name is a hash. Birth data stays inside the scratch file.
Files older than two days are removed.
"""
from __future__ import annotations
@@ -9,13 +11,15 @@ from __future__ import annotations
import hashlib
import json
import time
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[1]
CACHE_SCHEMA = "consult-full-data-cache-v1"
CACHE_SCHEMA = "consult-full-data-cache-v2"
SLOW_MISS_SECONDS = 20.0
KEEP_SECONDS = 2 * 24 * 60 * 60
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
@@ -41,7 +45,19 @@ def _number(value: Any) -> int | float:
return number
def identity_from_body(body: dict[str, Any]) -> dict[str, Any]:
def reference_date_from_body(body: dict[str, Any], *, today: date | None = None) -> str:
"""One date for dasha, transits and the year chart. Request first, else the server day."""
for key in ("today", "transit_date"):
raw = body.get(key)
if isinstance(raw, str) and len(raw) >= 10:
try:
return date.fromisoformat(raw[:10]).isoformat()
except ValueError:
continue
return (today or date.today()).isoformat()
def identity_from_body(body: dict[str, Any], *, today: date | None = None) -> dict[str, Any]:
"""Birth fields are required. Missing keys raise KeyError so the caller can gap."""
node = body.get("node_mode", body.get("nodeMode", "mean"))
ayanamsa = body.get("ayanamsa", "lahiri")
@@ -60,6 +76,7 @@ def identity_from_body(body: dict[str, Any]) -> dict[str, Any]:
"ayanamsa": str(ayanamsa),
"node_mode": str(node or "mean"),
"engine_version": engine_version(),
"reference_date": reference_date_from_body(body, today=today),
}
@@ -72,6 +89,21 @@ def _cache_path(identity: dict[str, Any]) -> Path:
return cache_dir() / f"{cache_key(identity)}.json"
def prune_cache(now: float | None = None) -> int:
"""Drop cache files last written more than two days ago."""
cutoff = (time.time() if now is None else now) - KEEP_SECONDS
removed = 0
folder = cache_dir()
for path in folder.glob("*.json"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
except OSError:
continue
return removed
def get_or_build(
identity: dict[str, Any],
builder: PacketBuilder | None = None,
@@ -79,6 +111,7 @@ def get_or_build(
clock: Callable[[], float] | None = None,
) -> dict[str, Any]:
"""Return the packet. A miss calls builder once and stores the packet."""
prune_cache()
now = clock or time.perf_counter
started = now()
path = _cache_path(identity)
@@ -101,14 +134,29 @@ def get_or_build(
}
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
"""Copy the engine's whole-sign aspects onto the consult packet. Do not recompute."""
modules = reading.get("modules") if isinstance(reading, dict) else None
aspects = modules.get("aspects") if isinstance(modules, dict) else None
rows = aspects.get("house_aspects") if isinstance(aspects, dict) else None
worksheets = packet.get("worksheets")
if not isinstance(rows, list) or not isinstance(worksheets, dict):
return packet
if "graha_drishti" not in worksheets:
worksheets["graha_drishti"] = {
"source": "modules.aspects.house_aspects",
"citation": "references/signs-and-houses.md",
"house_aspects": rows,
}
return packet
def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
"""Packet-only build. No markdown render and no sanitized rewrite."""
from datetime import date
from calculation_profile_contract import attach_calculation_profile
from jyotish_engine import build_pl9_style_export_packet, cmd_full_reading
today = date.today()
ref = date.fromisoformat(str(identity["reference_date"]))
args = SimpleNamespace(
year=int(identity["year"]),
month=int(identity["month"]),
@@ -121,19 +169,41 @@ def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
tz=float(identity["tz"]),
ayanamsa=identity["ayanamsa"],
node_mode=identity["node_mode"],
today=today.isoformat(),
target_year=today.year,
age=today.year - int(identity["year"]),
transit_date=today.isoformat(),
today=ref.isoformat(),
target_year=ref.year,
age=ref.year - int(identity["year"]),
transit_date=ref.isoformat(),
birth_time_accuracy="confirmed",
)
reading = cmd_full_reading(args)
if not isinstance(reading, dict) or reading.get("error"):
raise RuntimeError("full_reading_unavailable")
packet = build_pl9_style_export_packet(reading)
if not isinstance(packet, dict):
raise RuntimeError("full_reading_unavailable")
_attach_graha_drishti(packet, reading)
return attach_calculation_profile(packet, args)
def warm_consult_packet(body: dict[str, Any] | None) -> dict[str, Any]:
"""Build today's packet after a chart save. The response stays small."""
try:
cached = get_or_build(identity_from_body(dict(body or {})))
except Exception as exc:
return {
"success": False,
"endpoint": "consult_card_warm",
"error_type": type(exc).__name__,
}
return {
"success": True,
"endpoint": "consult_card_warm",
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
def _requested_domains(body: dict[str, Any]) -> list[str]:
# Card domains (parents, children) are not the engine theme (family).
raw = body.get("consult_card_domains")
+10 -3
View File
@@ -4331,16 +4331,23 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
})
def _compute_consultation_workflow(self, body):
payload = dict(body or {})
if payload.get('consult_card_warm_only') is True:
try:
from scripts.consult_full_data_cache import warm_consult_packet
except ImportError:
from consult_full_data_cache import warm_consult_packet
return warm_consult_packet(payload)
result = execute_consultation_workflow(
self,
body=dict(body or {}),
surface=body.get('surface', 'api_web'),
body=payload,
surface=payload.get('surface', 'api_web'),
)
try:
from scripts.consult_full_data_cache import attach_consult_full_data
except ImportError:
from consult_full_data_cache import attach_consult_full_data
return attach_consult_full_data(result, body or {})
return attach_consult_full_data(result, payload)
def _build_audited_remedies_from_guided_topics(self, guided_topics):
if not isinstance(guided_topics, list):
@@ -0,0 +1,160 @@
"""Freeze consult-card facts from the real engine.
Run from the repo root:
python scripts/research/freeze_consult_card_facts_fixture.py
The reference date is pinned to 2026-09-27 so the fixture matches the
biography golden. The file is the server copy (paths included). The model
card drops those paths.
python scripts/research/freeze_consult_card_facts_fixture.py --biography-out <path>
also writes the nine public biography charts for parents, marriage, health
and career. That file stays outside the repo.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
from consult_card_domain_facts import CONSULTATION_DOMAINS, build_domain_facts # noqa: E402
from consult_full_data_cache import get_or_build, identity_from_body # noqa: E402
PUBLIC = ROOT / "references" / "public_oracle_cases.json"
TAYLOR = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json"
FICTIONAL = ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json"
BIOGRAPHY = ROOT / "frontend" / "tests" / "fixtures" / "consult-biography-backtest-golden.json"
OUT = ROOT / "frontend" / "tests" / "fixtures" / "consult-card-facts-20261009.json"
REFERENCE_DATE = "2026-09-27"
BIOGRAPHY_DOMAINS = ("parents", "marriage", "health", "career")
def _body_from_parts(year, month, day, hour, minute, second, lat, lon, tz) -> dict:
return {
"year": int(year),
"month": int(month),
"day": int(day),
"hour": hour,
"minute": minute,
"second": second,
"lat": lat,
"lon": lon,
"tz": tz,
"ayanamsa": "raman",
"node_mode": "mean",
"today": REFERENCE_DATE,
}
def _public(case_id: str) -> dict:
data = json.loads(PUBLIC.read_text(encoding="utf-8"))
birth = next(case["birth"] for case in data["cases"] if case.get("id") == case_id)
return _body_from_parts(
birth["year"], birth["month"], birth["day"],
birth["hour"], birth["minute"], birth.get("second", 0),
birth["lat"], birth["lon"], birth["tz"],
)
def _taylor() -> dict:
data = json.loads(TAYLOR.read_text(encoding="utf-8"))
birth = next(case["birth"] for case in data["cases"] if case.get("case_id") == "elizabeth_taylor_1932_aa_v4_holdout")
year, month, day = (int(part) for part in str(birth["date"]).split("-"))
clock = str(birth["time"]).split(":")
return _body_from_parts(
year, month, day,
int(clock[0]), int(clock[1]), int(clock[2]) if len(clock) > 2 else 0,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
)
def _fictional() -> dict:
data = json.loads(FICTIONAL.read_text(encoding="utf-8"))
birth = data["cases"]["fictional_reader_main"]["birth"]
return _body_from_parts(
birth["year"], birth["month"], birth["day"],
birth["hour"], birth["minute"], birth.get("second", 0),
birth["lat"], birth["lon"], birth["tz"],
)
def _zone(value) -> float:
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if text.upper().startswith("UTC"):
text = text[3:]
text = text.replace("+-", "+").replace("-+", "-")
return float(text)
def _biography_bodies() -> list[tuple[str, dict]]:
data = json.loads(BIOGRAPHY.read_text(encoding="utf-8"))
bodies = []
for figure in data["figures"]:
birth = figure["shared_chart"]["birth"]
year, month, day = (int(part) for part in str(birth["date"]).split("-"))
bodies.append((figure["id"], _body_from_parts(
year, month, day,
birth["hour"], birth["minute"], birth.get("second", 0),
birth["lat"], birth["lon"], _zone(birth["tz"]),
)))
return bodies
def _charts(pairs: list[tuple[str, dict]], domains: tuple[str, ...]) -> dict:
charts = {}
for label, body in pairs:
cached = get_or_build(identity_from_body(body))
built = {
domain: build_domain_facts(cached["packet"], domain, minute_confirmed=True)
for domain in domains
}
charts[label] = {"hit": cached["hit"], "elapsed_s": round(cached["elapsed_s"], 3), "domains": built}
print(f"{label} hit={cached['hit']} packet_s={cached['elapsed_s']:.3f}", flush=True)
return charts
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--biography-out")
args = parser.parse_args()
charts = _charts([
("jobs", _public("steve_jobs_1955_aa")),
("obama", _public("barack_obama_1961_aa")),
("taylor", _taylor()),
("fictional", _fictional()),
], CONSULTATION_DOMAINS)
payload = {
"source": "scripts/research/freeze_consult_card_facts_fixture.py",
"command": "python scripts/research/freeze_consult_card_facts_fixture.py",
"reference_date": REFERENCE_DATE,
"ayanamsa": "raman",
"node_mode": "mean",
"note": "Real engine packets. Server copy keeps paths. The model card must not.",
"charts": charts,
}
OUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print(OUT, flush=True)
if args.biography_out:
biography = {
"source": "scripts/research/freeze_consult_card_facts_fixture.py",
"reference_date": REFERENCE_DATE,
"ayanamsa": "raman",
"node_mode": "mean",
"figures": _charts(_biography_bodies(), BIOGRAPHY_DOMAINS),
}
destination = Path(args.biography_out)
destination.write_text(json.dumps(biography, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print(destination, flush=True)
if __name__ == "__main__":
main()
+102
View File
@@ -20,8 +20,13 @@ from consult_card_domain_facts import (
)
from consult_full_data_cache import (
attach_consult_full_data,
build_full_data_packet,
cache_dir,
cache_key,
get_or_build,
identity_from_body,
prune_cache,
warm_consult_packet,
)
ROOT = Path(__file__).resolve().parents[1]
@@ -196,7 +201,104 @@ def test_public_charts_copy_every_domain(label, body):
assert_fact_leaves(packet, facts)
assert "tajika_named_yogas" not in json.dumps(facts, ensure_ascii=False)
assert any(row["id"].startswith("house:") or row["kind"] == "blocked" for row in facts["factors"])
aspects = next(item for item in facts["participation"] if item["id"] == "parashari_aspects")
assert aspects["status"] == "value", domain
assert aspects["citation"].startswith("references/signs-and-houses.md")
filled = [
name for name, row in facts["planets"].items()
if isinstance(row, dict) and (
(isinstance(row.get("aspected_by"), dict) and row["aspected_by"].get("value"))
or (isinstance(row.get("aspects_houses"), dict) and row["aspects_houses"].get("value"))
)
]
assert filled, f"{label} {domain}"
md = facts["timing"]["md"]
assert md["status"] == "value", domain
assert md["value"] == packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["current_dasha"]["lord"]
assert slowest < 1.66, f"{label} domain read {slowest:.3f}s"
# Timing only. Birth fields stay out of the assertion text.
assert cached["elapsed_s"] >= 0
print(f"{label} hit={cached['hit']} packet_s={cached['elapsed_s']:.3f} domain_s={slowest:.3f}")
def _birth():
return {
"year": 1901,
"month": 1,
"day": 1,
"hour": 0,
"minute": 1,
"second": 0,
"lat": 0,
"lon": 0,
"tz": 0,
"ayanamsa": "raman",
"node_mode": "mean",
}
def test_reference_date_changes_the_cache_key_and_old_files_are_removed():
early = identity_from_body({**_birth(), "today": "2001-01-01"})
later = identity_from_body({**_birth(), "transit_date": "2001-06-01"})
assert early["reference_date"] == "2001-01-01"
assert later["reference_date"] == "2001-06-01"
assert cache_key(early) != cache_key(later)
seen = []
def builder(identity):
seen.append(identity["reference_date"])
return {"reference_date": identity["reference_date"], "lord": identity["reference_date"]}
for identity in (early, later):
(cache_dir() / f"{cache_key(identity)}.json").unlink(missing_ok=True)
first = get_or_build(early, builder)
second = get_or_build(later, builder)
assert first["hit"] is False and second["hit"] is False
assert first["packet"]["lord"] != second["packet"]["lord"]
assert seen == ["2001-01-01", "2001-06-01"]
stale = cache_dir() / f"stale-{uuid.uuid4().hex}.json"
stale.write_text("{}", encoding="utf-8")
old = time.time() - (3 * 24 * 60 * 60)
os_utime = __import__("os").utime
os_utime(stale, (old, old))
assert prune_cache() >= 1
assert not stale.exists()
assert (cache_dir() / f"{cache_key(early)}.json").is_file()
def test_packet_builder_uses_one_reference_date_and_copies_house_aspects(monkeypatch):
seen = {}
def fake_reading(args):
seen["today"] = args.today
seen["transit_date"] = args.transit_date
seen["target_year"] = args.target_year
return {
"modules": {
"aspects": {
"house_aspects": [
{"planet": "Saturn", "house": 1, "aspects_to": {7: {"aspect_type": "7宫相位"}}},
],
},
},
}
def fake_packet(_reading):
return {"worksheets": {}, "core_chart": {}}
import calculation_profile_contract
import jyotish_engine
monkeypatch.setattr(jyotish_engine, "cmd_full_reading", fake_reading)
monkeypatch.setattr(jyotish_engine, "build_pl9_style_export_packet", fake_packet)
monkeypatch.setattr(calculation_profile_contract, "attach_calculation_profile", lambda packet, _args: packet)
packet = build_full_data_packet({**_birth(), "reference_date": "1999-05-06"})
assert seen == {"today": "1999-05-06", "transit_date": "1999-05-06", "target_year": 1999}
assert packet["worksheets"]["graha_drishti"]["house_aspects"][0]["planet"] == "Saturn"
def test_warm_response_does_not_include_the_packet():
warmed = warm_consult_packet({})
assert warmed["success"] is False
assert "packet" not in warmed
assert warmed["error_type"]