diff --git a/CHANGELOG.md b/CHANGELOG.md index 04301aed..7eaffc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-09 — 对话里每个问题带上一张和完整报告同一份资料的综合判断表(未上线) + +- 对话里每个问题会带上一张和完整报告同一份资料的综合判断表。需要再看的资料,每次对话最多再取 3 次。 +- 表上的数字从那份资料原样抄来。罗睺、计都不单独定功能吉凶。受冲只计数,不按条数下判断。 +- 出生时间没有确认到分钟时,更细的分盘和 Ghati 不参与,卡上写「出生时间精度不够,不参与」。 +- KP 仍不作为结论依据。Skill 版本不变。 + ## 2026-10-09 — 完整数据版补上六种星座大运(未合入,未上线) - 完整数据版增加 Sthira、Niryaana Shoola、Drig、Navamsha、Shoola、Chara。每节写本站选用的方法和书名页码,并写明未与外部软件逐一核对。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fca7c843..5c8e1684 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17327,3 +17327,51 @@ - 复发自:无。 - 修复版本:分支 `codex/rashi-dashas-native-20261008`,未合入,未上线。 - 验收补记(Claude,2026-10-09):本分支的 PyJHora 对照文件原先拿 Raman 本命盘比 Lahiri 的 PyJHora 盘;已改为同一 Lahiri 盘重冻,差异表重写(见进度记录)。报告代码不受影响。 + +## BUG-1293 | 对话数据卡自己拼了一套数,没有综合判断表 + +- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-09 +- 来源:产品反馈对话「只看本命盘或 D10」;任务书 `TASK-consult-card-full-source-20261007`。 +- 影响面:普通对话证据卡的领域段。旧的基础投影仍在,避免首轮回答的字段名变化。新的权威数字是领域段上的综合判断表。 +- 现象:事业卡大约 7,600 字符,自然代表星没有被点名,大运主星和事业宫的关系没有表,D10 联动只能卡外补取。 +- 触发条件:普通对话计算完成后组装证据卡。 +- 根因:09-27 为了把模型可见文本压到约 1.2 万字符,按当时清单挑字段;对话和报告各走一条取数路径。 +- 修复:对话卡从完整数据版同一个资料包按领域取叶子(`read_full_data_domain`)。卡片构建器组装综合判断表,模型只解读。数值原样复制。缺的写 `gaps`。没有本仓 `references/` 出处的参与项写 `blocked`。罗睺、计都功能角色留空,表头注明「罗计不单独定功能吉凶」。受冲条数只计数。D16 及以上与 Ghati 只在出生时间已确认或校正已交付分钟时参与。 +- 验证:`frontend/tests/consult-card-full-source-20261007.test.ts`(复制、无门槛、Vargottama 由两处星座推导、照进入关系缺口、无事实时不上表)。`tests/test_consult_card_full_source.py` 对虚构密度盘和三张公开盘逐格对照资料包,并锁精度闸。本机没有模型 key,生平回测未跑。 +- 防复发:上述测试;事实 JSON 若含 `tajika_named_yogas` 直接拒绝(对话年运仍读 `tajika_yogas`,BUG-1282)。 +- 相关记录:BUG-1160、BUG-1161、BUG-1221、BUG-1282。 +- 复发自:无。 +- 修复版本:本分支提交。未部署。 + +## BUG-1294 | 对话卡外补取每轮只能 1 次 + +- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-09 +- 来源:产品 2026-10-08 确认把卡外补取上限从 1 改为 3。 +- 影响面:`read-consultation-evidence`。第 4 次仍拒绝。KP 补取仍带参考说明,不作结论。 +- 现象:需要多看的分盘、Argala、定位星链或三盘联动时,一轮里第二次就被拒绝。 +- 触发条件:同一轮里第二次调用补取工具。 +- 根因:09-27 把 `MAX_EVIDENCE_LOOKUPS_PER_TURN` 定为 1。 +- 修复:上限改为 3。工具说明写明第四次拒绝。系统提示里对应的那一句改短了 20 个字符,没有把读法说明加进系统提示。步数上限仍是 8:一次计算、三次成功补取、一次被拒绝的第四次、再写回答,实测仍能走完。 +- 验证:`frontend/tests/consult-evidence-lookup-20260927.test.ts` 前三次返回该节、第四次 `lookup_limit_reached`,步骤为 completed、completed、completed、failed。 +- 防复发:该测试锁上限 3 与第四次拒绝。 +- 相关记录:BUG-1293、BUG-1256。 +- 复发自:无。 +- 修复版本:本分支提交。未部署。 + +## BUG-1295 | 对话进一步阅读指向 oracle 与治理文件 + +- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-09 +- 来源:任务书 T5。产品确认 `further_reading` 改为按领域的技法资料目录。 +- 影响面:普通对话工具结果里的 `methodology.further_reading`。不接书籍语料,不做每轮联网。 +- 现象:目录里约 19 条多为 oracle / 治理文件,技法章节不在列。 +- 触发条件:对话计算返回 methodology。 +- 根因:目录按技能包点名的引用收集,没有按领域筛技法文件。 +- 修复:每个领域最多 8 条,格式 `领域: references/...`,去掉 oracle / 治理文件,路径必须真实存在。清单写明需要时用 `skill_read` 读冒号后面的章节。新增读法不进系统提示。 +- 验证:`frontend/tests/consultation-methodology.test.ts` 对 12 个领域检查条数、前缀和路径。 +- 防复发:该测试。 +- 相关记录:BUG-1293。 +- 复发自:无。 +- 修复版本:本分支提交。未部署。 diff --git a/docs/tasks/PROGRESS-consult-card-full-source-20261007.md b/docs/tasks/PROGRESS-consult-card-full-source-20261007.md new file mode 100644 index 00000000..f30f3211 --- /dev/null +++ b/docs/tasks/PROGRESS-consult-card-full-source-20261007.md @@ -0,0 +1,96 @@ +# PROGRESS · 对话数据卡与报告同源(2026-10-09) + +分支 `codex/consult-card-full-source-20261007`。基线 HEAD `e1c7383c`。本地未推送。状态:待验收。 + +## 做了什么 + +对话卡的新数字从完整数据版同一个资料包来,入口是 `read_full_data_domain`。12 个领域一次做完。每个领域一张综合判断表,由卡片构建器组装,模型只解读。旧的基础投影留着,所以首轮回答的字段名不变;新的权威数字是领域段上的表。表是资料包里的挑选,不是第二套计算。 + +卡版本仍是 `evidence-card-v2`。没有事实时不上表,golden 的 `meta.gaps` 仍为空。年运对话继续读 `tajika_yogas`,不读 `tajika_named_yogas`。KP 不上卡,补取仍带参考说明。不做每轮联网,不接书籍语料。 + +`scripts/jyotish_api_server.py` 只在原有的咨询计算入口上薄薄包了一层,主体在 `scripts/consult_full_data_cache.py` 与 `scripts/consult_card_domain_facts.py`。类方法数仍是 218。文件换行数 10,956,粗护栏上限 11,224。`page.tsx` 的 useState / useRef 没有增加。没有第二个聊天输入框。 + +## T0 · 12 领域对照 + +共用层里,Parashari 星照在 `references/strict-workflow-router.md` 找不到「单独列为参与层」的段落,12 个领域都是 `blocked`。表上的「照」因此记在关系缺口里,不重算。KP 按产品决定写「不参与」。其余共用层有出处:Sudarshana、D9、Bhava Chalit、Shadbala、Bhava Bala、Avastha、Vimsopaka、八分法、功能吉凶、Jaimini 星照、Argala、定位星链、Vimshottari、Narayana、Yogini、行运、双重过运、瑜伽 hits。 + +| 领域 | 专属项出处 | blocked | +| --- | --- | --- | +| 事业 | D10、A10、AL、AmK、Karakamsha、Ghati、太阳/土星/水星/木星、10 宫、D1/D9/D10 联动:`strict-workflow-router.md`、`event_judgment_career.md`、`jaimini-complete-system.md`、`deep-analysis-complete-workflow.md`、`pdf-chart-reading-guide.md` | Parashari 星照;Vighati(`references/` 无条目) | +| 财富 | D2、D11、Hora、Sree、木星/金星/水星、2/11/5/9/10 宫:`event_judgment_wealth.md`、`deep-analysis-complete-workflow.md`、`promise-assessment-templates.md` | Parashari 星照;Indu;从 AL 起第 2 宫;从 AL 起第 11 宫 | +| 婚恋 | D9 第 7 宫、UL、DK、金星、木星、Vivah Saham、火星、7 宫:`event_judgment_marriage.md`、`jaimini-complete-system.md` | Parashari 星照;UL 起第 2 宫;A7 | +| 健康 | D6、D8、D30、第 22 个 Drekkana、Badhaka、太阳/月亮/土星/火星、1/6/8/12 宫:`strict-workflow-router.md`、`shodasavarga-complete-guide.md`、`badhaka-obstacle-planet-guide.md` | Parashari 星照;第 64 个 Navamsa;Gulika;Mandi;Pranapada(问事章有,未列入本命健康) | +| 子女 | D7、PK、木星、5 宫:`strict-workflow-router.md`、`jaimini-complete-system.md`、`house-domain-planet-mapping.md` | Parashari 星照 | +| 父母 | D12、MK、PiK、太阳、月亮、4 宫、9 宫:同上三份 | Parashari 星照 | +| 学业 | D24、水星、木星、4/5/9 宫:`strict-workflow-router.md`、`house-domain-planet-mapping.md` | Parashari 星照;D5(未找到标成学业分盘的段落) | +| 迁居 | D4、罗睺、4/9/12 宫:`birth-time-rectification-decision-tree.md`、`house-domain-planet-mapping.md` | Parashari 星照;D12(路由器标给家族,未标给迁居) | +| 家庭 | D12:`strict-workflow-router.md` | Parashari 星照;D4(出处是搬迁,不是家庭);D7(出处是子女);自然代表星整组 | +| 流年 | Tajika、Muntha、年主、Mudda、Sahams:`varshaphala-annual-chart-guide.md` | Parashari 星照;单独的自然代表星;单一本命宫 | +| 时间 | Narayana 子运、年盘:`bphs-ch48-narayana-dasha.md`、`varshaphala-annual-chart-guide.md` | Parashari 星照;单独的自然代表星;单一本命宫 | +| 综合 | D1、D9、AL、Karakamsha、1 宫:`strict-workflow-router.md`、`pdf-chart-reading-guide.md`、`jaimini-complete-system.md`、`house-domain-planet-mapping.md` | Parashari 星照;D10(标给事业);D2(标给财富);自然代表星整组 | + +自然代表星没有出处的领域是家庭、流年、时间、综合,写的是 `blocked`,没有自拟。家庭领域的分盘列仍是 D4,参与清单里的家庭 D4 是 `blocked`:出处说的是搬迁,不是家庭。这是故意分开的。 + +Argala 与定位星链有出处,卡上放的是路径引用,不在对话卡里重算。若以后要把 `argala.houses` 的净结果抄上卡,那是另一次叶子复制。 + +## T1 · 缓存 + +键是年月日时分秒、经纬度、时区、岁差、交点模式、引擎版本、schema `consult-full-data-cache-v1`。文件名是 sha256,放在 `scratch/local/consult_full_data_cache`(服务器上这块目录就是 `api_scratch` 卷)。键里没有「今天」,所以当前大运冻在第一次计算的那天。改出生资料会换成另一把键(BUG-1251)。精度闸在取事实时做,不进缓存键。未命中同步计算,没有另加超时绕过。超过 20 秒会标 `slow`,本机没有出现。 + +| 情况 | 未命中整包 | 领域取数 | +| --- | --- | --- | +| 虚构盘 | 7.342 秒 | — | +| 乔布斯公开盘 | 7.075 秒 | 0.145 秒 | +| 奥巴马公开盘 | 4.668 秒 | 0.118 秒 | +| 泰勒公开盘 | 4.421 秒 | 0.104 秒 | +| 乔布斯同一身份再取 | 命中,装入 0.044 秒 | 十二领域合计 0.552 秒,约 0.046 秒/域 | + +都低于 20 秒,命中后的领域取数也低于约 1.66 秒/域。没有另做事实侧车缓存。 + +## T2–T5 + +综合判断表:每个因素一行。列按任务书。宫主另起一行。受冲是计数加明细,表里没有门槛,也没有「两个及以上」。Vargottama 由抄来的 D1 星座和 D9 星座是否相同推导。罗计功能角色为空。大层 JSON 超过 800 字符或含 `tajika_named_yogas` 时只留路径、检查记 `nonempty`;判断表上的标量格子是全文复制。 + +乔布斯、奥巴马、泰勒三张公开盘 × 12 领域,以及虚构密度盘 × 12 领域,叶子对照通过。 + +精度闸:`declared_accuracy` 为 `rectified`,或 `birth_time_status` 为 accepted / confirmed,或请求里明确带了 `birth_time_accuracy=confirmed`,并且有时、分,才放行 D16 及以上与 Ghati。只申报了分钟、尚未接受,仍然不放行。 + +补取上限 3。测试:前三次成功,第四次 `lookup_limit_reached`。`AGENT_MAX_STEPS` 仍是 8。一次计算加三次成功补取、一次被拒绝的第四次、再写回答,测试跑完了,所以不加步数。 + +`further_reading` 每领域 1–8 条,形如 `领域: references/...`,无 oracle、无治理文件。索引上限 24 改为 48。共享方法正文没有加长。 + +临时上限:卡 ≤ 20,000 字符,加清单 ≤ 26,000。最终上限等 T6,本单不定。 + +## T6 / T7 · 环境缺口 + +本机没有模型 key。没有寻找、没有读取、没有发明 key。没有输入 token、首字时间、总耗时、空答率,也没有生平回测的冲突数。这两项不能写成通过。 + +## 系统提示 + +BUG-1256 修完后的本命系统提示是 26,591 字符,上限 26.6K。本单只把「补取一次」那句改成「最多三次」,新句短 20 字符。按 Linux 上真实技能文件(本机该文件是一行相对指针)用同一套摘录规则测量:26,571 字符,仍 ≤ 26,600。方法块仍小于 4,000。首轮形状与口吻没有改。新增读法在清单里。 + +## 本机验证 + +- `tsc --noEmit`:退出码 0。 +- `npm run lint`:退出码 0,0 error。既有 warning 未动。 +- 相关前端测试:综合判断表、补取 3/4、清单、资料目录、口吻、预算断言通过。 +- `skill-binding.test.ts` 开头两条在本机失败:`skills/jyotish-vedic-astrology/SKILL.md` 在 Windows 上是一行 `../../SKILL.md`,不是符号链接。该文件本单没有改。Linux 上 `readFileSync` 会跟到真文件。其余该文件测试通过,方法块长度断言通过。 +- Python:`tests/test_consult_card_full_source.py`(不含三张公开盘的快测)与 `tests/test_api_server_growth_contract.py` 通过。三张公开盘的未命中耗时见上表,通过。 +- 未跑:Linux 全量、快速门、`npm test` 全套、`next build`、浏览器走查。不能声称这些通过。 + +## 改过的既有断言 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 补取上限 | 1 | 3 | 产品 2026-10-08,BUG-1294 | +| 补取测试 | 第二次拒绝 | 第三次成功、第四次拒绝 | 同上 | +| 系统提示句子 | once | at most three times | 同句短 20 字符,BUG-1256 | +| 共用清单条数 | 8 | 9 | 受冲只计数、资料用 skill_read,BUG-1293 | +| 清单字段扫描 | 只放过 blocked | 再放过 skill_read、further_reading | 这两个词是工具和目录名 | +| 卡预算三处 | 12,500 / 18,500 | 20,000 / 26,000 | 产品临时上限,最终等 T6 | + +资料目录测试改为按 12 个领域检查真实路径。行走源码的测试在 Windows 上改用 `fileURLToPath`,期望的相对路径名单没变。 + +## 明确没做 + +没有 `git push`,没有推 `staging`,没有改 workflow,没有提升 `main`。没有模型 key。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 3f69a4dd..99f8b2ab 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -129,9 +129,8 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-consult-card-full-source-20261007.md` | — | **对话数据卡与报告同源 + 领域综合判断表**:事业卡只有约 7,600 字符、无自然代表星、无大运主星关系、D10 联动只能补取、补取上限 1;全 12 领域;综合判断表、参与清单(不止 D1/D9,含精度闸)、补取 3 次、资料目录均已确认;KP 不上卡、不联网、不接书籍语料;排在 sync6 与报告单之后 | 验收未通过(`35dafd1e`,见 fix-20261009) | — | -| `TASK-consult-card-full-source-fix-20261009.md` | — | **对话卡同源修复**:缓存键无日期致当前大运冻结、整卡 37.5K(每格带内部路径)、星照整列 blocked(资料库其实有出处)、前端测试手写数据、无预热;合入前用临时 key 跑模型 A/B 与生平回测 | F1–F5 已验收,F6 未过(`e47c161a`,见 fix2) | — | -| `TASK-consult-card-full-source-fix2-20261009.md` | — | **对话卡同源第二轮**:齐达内事业被「水星=说写算」带偏致严重冲突 1→3;事业清单加「代表星只看力量受冲、不按星性猜职业」后重跑回测(≤1 才合入);预热改为不占重计算名额 | 待领取 | — | +| `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 与生平回测 | 待领取 | — | | — (产品 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 | diff --git a/docs/testing/consult-card-full-source-20261007.md b/docs/testing/consult-card-full-source-20261007.md new file mode 100644 index 00000000..73e0ee50 --- /dev/null +++ b/docs/testing/consult-card-full-source-20261007.md @@ -0,0 +1,12 @@ +# 真机清单:对话综合判断表(2026-10-09) + +对应任务书 `docs/tasks/TASK-consult-card-full-source-20261007.md`。部署到 staging 之后照做。本机没有模型 key,下面的结果列留空。 + +准备:用一个出生时间已确认的账号,新开普通对话。每题只问一次。看回答有没有用到该领域综合判断表里的因素(宫、宫主、自然代表星、当前大运主星、受冲是计数而不是结论)。 + +| # | 领域 | 问法 | 应看到 | 结果 | +| --- | --- | --- | --- | --- | +| 1 | 事业 | 我的事业会往什么方向走? | 回答用到事业综合判断表里的因素 | | +| 2 | 婚恋 | 我的婚姻和感情会是什么样? | 回答用到婚恋综合判断表里的因素 | | +| 3 | 财富 | 我的财运怎么样? | 回答用到财富综合判断表里的因素 | | +| 4 | 健康 | 我的身体底子怎么样? | 回答用到健康综合判断表里的因素 | | diff --git a/frontend/src/lib/consultation-condensed-checklist.ts b/frontend/src/lib/consultation-condensed-checklist.ts index 51d33d87..29052555 100644 --- a/frontend/src/lib/consultation-condensed-checklist.ts +++ b/frontend/src/lib/consultation-condensed-checklist.ts @@ -65,6 +65,7 @@ export const CONDENSED_SHARED_READING_LINES: readonly string[] = [ "不断言死亡、离婚、疾病诊断、流产、不孕。", "瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。", "`traditional_yogas` 只用来说明力量,不单独下结论,不算受冲,也不抵消受冲。", + "综合判断表上的受冲条数只是计数,不是结论,也不按条数门槛下判断。需要时用 skill_read 读 further_reading 里冒号后面的章节。", ]; /** The shared reading as one numbered block. */ @@ -87,7 +88,7 @@ export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly, gaps: string[]) { * Narayana beside the legacy one, running MD only and in its short form: * label, start sign, MD sign and its start / end age. The MD lord, its years * (end_age − start_age), the deciding level and the sub-periods stay in the - * report. Product 2026-10-03: card budget 12,500, with the method checklist - * 18,500 (was 12,000 / 18,000, BUG-1160/1161). + * report. Product 2026-10-08: temporary card budget 20,000, with the checklist + * 26,000 (was 12,500 / 18,500). The final ceiling waits on a measured run. */ const RATH_MD_CARD_KEYS = ["sign", "start_age", "end_age", "dual_lord_basis", "dual_lord_basis_en"] as const; @@ -1010,9 +1019,17 @@ function onCard(card: Pick, section: E return key ? sections.some((item) => item && item[key] !== undefined) : false; } +function domainFacts(source: EvidenceCardSource): Record | null { + const body = record(record(source.context).consult_card_facts); + if (body.schema !== "consult-card-facts-v1" || !Array.isArray(body.domains)) return null; + const hit = body.domains.find((item) => record(item).domain === source.domain); + return hit ? record(hit) : null; +} + /** * Build the card for up to two domains (the existing per-turn cap). The base * section is taken once, from the first domain's sources; it is the same chart. + * A judgment table is attached only when this turn's facts name the domain. */ export function buildEvidenceCard( sources: readonly EvidenceCardSource[], @@ -1057,6 +1074,11 @@ export function buildEvidenceCard( } const section = domainSection(source.domain, source, own, gaps, planFor(source.domain), options); 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; + } domainSections[source.domain] = section; } const card: EvidenceCard = { diff --git a/frontend/src/lib/consultation-judgment-table.ts b/frontend/src/lib/consultation-judgment-table.ts new file mode 100644 index 00000000..c9810172 --- /dev/null +++ b/frontend/src/lib/consultation-judgment-table.ts @@ -0,0 +1,306 @@ +/** + * One judgment table per consult domain (TASK-consult-card-full-source-20261007). + * + * The Python facts module copies packet leaves. This file only lays those + * leaves into rows and columns. It does not round, translate, or decide that + * an affliction count means anything. + */ + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonRecord = { [key: string]: Json }; + +export const JUDGMENT_TABLE_LABELS = { + rahuNote: "罗计不单独定功能吉凶", + precision: "出生时间精度不够,不参与", + relationLord: "主管", + relationOccupant: "落入", + relationAspect: "照", + relationConjunctLord: "与宫主同宫", +} as const; + +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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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); + if (Array.isArray(value)) return value.every(isJson); + if (typeof value === "object") return Object.values(value as object).every(isJson); + return false; +} + +function copyJson(value: unknown): Json | undefined { + if (!isJson(value)) return undefined; + return structuredClone(value); +} + +function leaf(value: unknown): JsonRecord { + const row = record(value); + const out: JsonRecord = {}; + if (typeof row.status === "string") out.status = row.status; + if (typeof row.path === "string") out.path = row.path; + if (typeof row.check === "string") out.check = row.check; + if (typeof row.note === "string") out.note = row.note; + if ("value" in row) { + const copied = copyJson(row.value); + if (copied !== undefined) out.value = copied; + else if (row.value === null) out.value = null; + } + if (typeof row.index === "number") out.index = row.index; + return out; +} + +function leafValue(value: unknown): Json | undefined { + const row = leaf(value); + if (row.status !== "value" && row.status !== "withheld") return undefined; + return "value" in row ? row.value : undefined; +} + +function textList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function debilitated(status: unknown): boolean { + return typeof status === "string" + && (status.includes("Debilitated") || status.includes("落陷")) + && !status.includes("取消"); +} + +function houseCount(from: number, to: number): number { + return ((to - from + 12) % 12) + 1; +} + +type Detail = { signal: number; via: string; value: Json }; + +function afflictionForPlanet( + planet: string, + planets: Record, + houses: Record, + role: string | undefined, +): JsonRecord { + const row = record(planets[planet]); + const details: Detail[] = []; + const unevaluated: string[] = []; + const coTenants = textList(leafValue(row.co_tenants)); + const withMalefic = MALEFIC_BODIES.filter((name) => coTenants.includes(name)); + if (withMalefic.length) details.push({ signal: 1, via: "co_tenants", value: withMalefic }); + const aspectLeaf = leaf(row.aspected_by); + if (aspectLeaf.status !== "value") unevaluated.push("aspected_by"); + else { + const by = ASPECT_MALEFICS.filter((name) => textList(aspectLeaf.value).includes(name)); + if (by.length) details.push({ signal: 1, via: "aspected_by", value: by }); + } + const house = leafValue(row.d1_house); + const isLord = role === "lord" || [...Object.values(houses)].some((item) => leafValue(record(item).lord) === planet); + if (isLord && typeof house === "number") { + const evidence: Json[] = []; + if (DUSTHANA.has(house)) evidence.push(house); + for (const number of Object.keys(houses)) { + const domainHouse = Number(number); + if (!Number.isInteger(domainHouse)) continue; + const count = houseCount(domainHouse, house); + if (DUSTHANA.has(count)) evidence.push([domainHouse, count]); + } + if (evidence.length) details.push({ signal: 2, via: "d1_house", value: evidence }); + } + if (leafValue(row.functional_role) === "functional_malefics") { + details.push({ signal: 3, via: "functional_role", value: "functional_malefics" }); + } + const dignity = leafValue(row.d1_dignity); + if (debilitated(dignity)) details.push({ signal: 4, via: "d1_dignity", value: dignity as Json }); + if (leafValue(row.combust) === true) details.push({ signal: 4, via: "combust", value: true }); + const varga = leaf(row.domain_varga_house); + if (varga.status === "withheld" || varga.status === "gap" || varga.status === undefined) { + unevaluated.push("domain_varga_house"); + } else if (isLord && typeof varga.value === "number" && DUSTHANA.has(varga.value)) { + details.push({ signal: 5, via: "domain_varga_house", value: varga.value }); + } + return { + count: details.length, + details: details as Json[], + unevaluated, + }; +} + +function afflictionForHouse(number: number, planets: Record, houses: Record): JsonRecord { + const occupants = textList(leafValue(record(houses[String(number)]).occupants)); + const malefic = MALEFIC_BODIES.filter((name) => occupants.includes(name)); + const details: Detail[] = malefic.length ? [{ signal: 1, via: "occupants", value: malefic }] : []; + const unevaluated = ["aspected_by"]; + let vargaKnown = true; + for (const name of MALEFIC_BODIES) { + const varga = leaf(record(planets[name]).domain_varga_house); + if (varga.status !== "value") { + vargaKnown = false; + continue; + } + if (varga.value === number) details.push({ signal: 5, via: "domain_varga_house", value: [name, number] }); + } + if (!vargaKnown) unevaluated.push("domain_varga_house"); + return { count: details.length, details: details as Json[], unevaluated }; +} + +function relationsFor(planet: string, planets: Record, houses: Record): Json[] { + const house = leafValue(record(planets[planet]).d1_house); + const rows: Json[] = []; + for (const [number, item] of Object.entries(houses)) { + const domainHouse = Number(number); + const lord = leafValue(record(item).lord); + 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 (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); + } + rows.push({ house: domainHouse, labels }); + } + return rows; +} + +function vargottama(planetRow: Record): JsonRecord { + const d1 = leaf(planetRow.d1_sign); + const d9 = leaf(planetRow.d9_sign); + if (d1.status !== "value" || d9.status !== "value" || typeof d1.value !== "string" || typeof d9.value !== "string") { + return { status: "gap", check: "derived" }; + } + return { + status: "derived", + value: d1.value === d9.value, + derived_from: [d1.path ?? "", d9.path ?? ""], + check: "derived", + }; +} + +function savOf(numbers: number[], houses: Record): Json[] { + return numbers.map((number) => { + const house = record(houses[String(number)]); + return { + house: number, + sav_score: leaf(house.sav_score), + sav_sodhita: leaf(house.sav_sodhita), + }; + }); +} + +function coTenantRoles(planet: string, planets: Record): Json[] { + const names = textList(leafValue(record(planets[planet]).co_tenants)); + return names.map((name) => ({ + planet: name, + functional_role: leaf(record(planets[name]).functional_role), + })); +} + +function planetColumns( + planet: string, + planets: Record, + houses: Record, + role: string | undefined, +): JsonRecord { + const row = record(planets[planet]); + if (row.present === false || Object.keys(row).length === 0) { + return { planet, status: "gap" }; + } + const domainHouses = Object.keys(houses).map(Number).filter((number) => Number.isInteger(number)); + return { + planet, + d1_sign: leaf(row.d1_sign), + d1_house: leaf(row.d1_house), + d1_dignity: leaf(row.d1_dignity), + functional_role: leaf(row.functional_role), + rasi_lord: leaf(row.rasi_lord), + co_tenants: leaf(row.co_tenants), + co_tenant_roles: coTenantRoles(planet, planets), + shadbala_rupas: leaf(row.shadbala_rupas), + shadbala_min: leaf(row.shadbala_min), + bav: leaf(row.bav), + sav: savOf(domainHouses, houses), + affliction: afflictionForPlanet(planet, planets, houses, role), + d9_sign: leaf(row.d9_sign), + d9_dignity: leaf(row.d9_dignity), + vargottama: vargottama(row), + domain_varga_house: leaf(row.domain_varga_house), + relations: relationsFor(planet, planets, houses), + relation_gaps: [JUDGMENT_TABLE_LABELS.relationAspect], + }; +} + +/** Assemble one domain table from copied facts. Missing leaves stay gaps. */ +export function assembleJudgmentTable(facts: unknown): JsonRecord { + const body = record(facts); + const planets = record(body.planets); + const houses = record(body.houses); + const rows: Json[] = []; + const factors = Array.isArray(body.factors) ? body.factors : []; + for (const factor of factors) { + const item = record(factor); + const id = typeof item.id === "string" ? item.id : "factor"; + const citation = typeof item.citation === "string" ? item.citation : ""; + if (item.kind === "house" && typeof item.house === "number") { + const house = record(houses[String(item.house)]); + rows.push({ + id, + kind: "house", + citation, + house: item.house, + sign: leaf(house.sign), + lord: leaf(house.lord), + occupants: leaf(house.occupants), + sav: savOf([item.house], houses)[0] ?? null, + affliction: afflictionForHouse(item.house, planets, houses), + relation_gaps: [JUDGMENT_TABLE_LABELS.relationAspect], + }); + const lord = leafValue(house.lord); + if (typeof lord === "string") { + rows.push({ + id: `${id}:lord`, + kind: "planet", + role: "lord", + citation, + ...planetColumns(lord, planets, houses, "lord"), + }); + } else { + rows.push({ id: `${id}:lord`, kind: "gap", citation, path: leaf(house.lord).path ?? null }); + } + continue; + } + if (item.kind === "planet" && typeof item.planet === "string") { + rows.push({ + id, + kind: "planet", + role: typeof item.role === "string" ? item.role : null, + citation, + ...planetColumns(item.planet, planets, houses, typeof item.role === "string" ? item.role : undefined), + }); + continue; + } + if (item.kind === "sign") { + rows.push({ + id, + kind: "sign", + citation, + sign: leaf(item.sign), + lord: leaf(item.lord), + affliction: { count: null, details: [], unevaluated: ["sign_row"] }, + }); + continue; + } + rows.push({ + id, + kind: typeof item.kind === "string" ? item.kind : "gap", + citation, + ...(typeof item.path === "string" ? { path: item.path } : {}), + }); + } + return { + header_note: typeof body.header_note === "string" ? body.header_note : JUDGMENT_TABLE_LABELS.rahuNote, + domain: typeof body.domain === "string" ? body.domain : "", + domain_varga: typeof body.domain_varga === "string" ? body.domain_varga : "", + rows, + }; +} diff --git a/frontend/src/lib/consultation-methodology.ts b/frontend/src/lib/consultation-methodology.ts index 4ed01aed..c1eed83e 100644 --- a/frontend/src/lib/consultation-methodology.ts +++ b/frontend/src/lib/consultation-methodology.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { z } from "zod"; import { @@ -84,8 +84,131 @@ const SKELETON_FILE = "references/event_judgment_skeleton.md"; /** Per-section and whole-block ceilings, so one long section cannot crowd out the evidence. */ export const METHODOLOGY_SECTION_MAX_CHARS = 6_000; export const METHODOLOGY_TOTAL_MAX_CHARS = 24_000; -/** How many of the references SKILL.md names are offered as a navigable index. */ -export const METHODOLOGY_INDEX_MAX_ENTRIES = 24; +/** Per-domain technique index: up to 8 files times the six-domain turn cap. */ +export const METHODOLOGY_INDEX_MAX_ENTRIES = 48; + +const DOMAIN_TECHNIQUE_PATHS: Readonly> = { + career: [ + "references/event_judgment_career.md", + "references/house-domain-planet-mapping.md", + "references/strict-workflow-router.md", + "references/shadbala-complete-methodology.md", + "references/ashtakavarga-complete-system.md", + "references/argala-complete-guide.md", + "references/jaimini-complete-system.md", + "references/varga-divisional-charts-quick-reference.md", + ], + marriage: [ + "references/event_judgment_marriage.md", + "references/navamsa-marriage-deep-analysis.md", + "references/darakaraka-complete-guide.md", + "references/navamsa-d9-interpretation-template.md", + "references/strict-workflow-router.md", + "references/jaimini-complete-system.md", + "references/house-domain-planet-mapping.md", + "references/shadbala-complete-methodology.md", + ], + wealth: [ + "references/event_judgment_wealth.md", + "references/house-domain-planet-mapping.md", + "references/strict-workflow-router.md", + "references/ashtakavarga-complete-system.md", + "references/shadbala-complete-methodology.md", + "references/varga-divisional-charts-quick-reference.md", + "references/deep-analysis-complete-workflow.md", + "references/promise-assessment-templates.md", + ], + health: [ + "references/strict-workflow-router.md", + "references/shodasavarga-complete-guide.md", + "references/badhaka-obstacle-planet-guide.md", + "references/house-domain-planet-mapping.md", + "references/shadbala-complete-methodology.md", + "references/ashtakavarga-complete-system.md", + "references/varga-divisional-charts-quick-reference.md", + "references/deep-varga-avastha-execution-guide.md", + ], + education: [ + "references/strict-workflow-router.md", + "references/house-domain-planet-mapping.md", + "references/varga-divisional-charts-quick-reference.md", + "references/shodasavarga-complete-guide.md", + "references/shadbala-complete-methodology.md", + "references/jaimini-complete-system.md", + "references/deep-analysis-complete-workflow.md", + "references/event_judgment_skeleton.md", + ], + migration: [ + "references/birth-time-rectification-decision-tree.md", + "references/house-domain-planet-mapping.md", + "references/strict-workflow-router.md", + "references/varga-divisional-charts-quick-reference.md", + "references/shadbala-complete-methodology.md", + "references/jaimini-complete-system.md", + "references/ashtakavarga-complete-system.md", + "references/event_judgment_skeleton.md", + ], + family: [ + "references/strict-workflow-router.md", + "references/house-domain-planet-mapping.md", + "references/varga-divisional-charts-quick-reference.md", + "references/jaimini-complete-system.md", + "references/shadbala-complete-methodology.md", + "references/event_judgment_skeleton.md", + "references/deep-analysis-complete-workflow.md", + "references/argala-complete-guide.md", + ], + parents: [ + "references/strict-workflow-router.md", + "references/house-domain-planet-mapping.md", + "references/jaimini-complete-system.md", + "references/varga-divisional-charts-quick-reference.md", + "references/shadbala-complete-methodology.md", + "references/shodasavarga-complete-guide.md", + "references/event_judgment_skeleton.md", + "references/deep-varga-avastha-execution-guide.md", + ], + children: [ + "references/strict-workflow-router.md", + "references/house-domain-planet-mapping.md", + "references/jaimini-complete-system.md", + "references/varga-divisional-charts-quick-reference.md", + "references/shadbala-complete-methodology.md", + "references/event_judgment_skeleton.md", + "references/ashtakavarga-complete-system.md", + "references/deep-analysis-complete-workflow.md", + ], + annual: [ + "references/varshaphala-annual-chart-guide.md", + "references/tajika-yoga-complete-guide.md", + "references/vimshottari_dasha_guide.md", + "references/bphs-ch48-narayana-dasha.md", + "references/strict-workflow-router.md", + "references/multi-dasha-convergence-protocol.md", + "references/alternative-dasha-systems.md", + "references/event_judgment_skeleton.md", + ], + timing: [ + "references/bphs-ch48-narayana-dasha.md", + "references/vimshottari_dasha_guide.md", + "references/multi-dasha-convergence-protocol.md", + "references/alternative-dasha-systems.md", + "references/varshaphala-annual-chart-guide.md", + "references/strict-workflow-router.md", + "references/event_judgment_skeleton.md", + "references/tajika-yoga-complete-guide.md", + ], + general: [ + "references/strict-workflow-router.md", + "references/house-domain-planet-mapping.md", + "references/event_judgment_skeleton.md", + "references/shadbala-complete-methodology.md", + "references/ashtakavarga-complete-system.md", + "references/jaimini-complete-system.md", + "references/pdf-chart-reading-guide.md", + "references/varga-divisional-charts-quick-reference.md", + ], +}; export const consultationMethodologySectionSchema = z.object({ title: z.string().max(200), @@ -111,12 +234,33 @@ const skill = resolveLiveJyotishSkill(); /** Live skill files are reread only after process restart; consult requests share one cache. */ const fileCache = new Map(); +/** A Windows checkout may store a skill symlink as a one-line relative path. */ +function resolvePackageFile(relativePath: string): string { + const parts = relativePath.split(/[\\/]/).filter(Boolean); + let current = skill.resolvedPath; + for (let index = 0; index < parts.length; index += 1) { + const next = resolve(current, parts[index]!); + if (index < parts.length - 1 && existsSync(next)) { + const stats = statSync(next); + if (stats.isFile() && stats.size <= 512) { + const text = readFileSync(next, "utf8").replace(/^\uFEFF/, "").trim(); + if (text.startsWith(".") && !/[\r\n\0]/.test(text)) { + current = resolve(current, text); + continue; + } + } + } + current = next; + } + return current; +} + function packageFile(relativePath: string): string | null { const hit = fileCache.get(relativePath); if (hit !== undefined) return hit; let content: string | null = null; try { - content = readFileSync(resolve(skill.resolvedPath, relativePath), "utf8"); + content = readFileSync(resolvePackageFile(relativePath), "utf8"); } catch { content = null; } @@ -124,6 +268,22 @@ function packageFile(relativePath: string): string | null { return content; } +function domainTechniqueIndex(domains: readonly ConsultationDomain[]): string[] { + const rows: string[] = []; + for (const domain of domains) { + let kept = 0; + for (const path of DOMAIN_TECHNIQUE_PATHS[domain]) { + if (kept >= 8) break; + if (path.includes("oracle/") || path.toLowerCase().includes("governance")) continue; + if (packageFile(path) === null) continue; + rows.push(`${domain}: ${path}`); + kept += 1; + if (rows.length >= METHODOLOGY_INDEX_MAX_ENTRIES) return rows; + } + } + return rows; +} + /** * The body of one `## ...` section, found by a heading substring so the section * numbering in the router can be renumbered without breaking the lookup. @@ -144,26 +304,6 @@ function clamp(text: string) { : text; } -/** - * The reference paths SKILL.md names, in the order it names them. - * - * Derived rather than listed so that the index follows the skill: when SKILL.md - * starts pointing somewhere else, the model is offered the new pointer without - * this file being edited. Paths that are not in the package are dropped, so a - * stale pointer cannot be advertised as readable. - */ -function namedReferences(instructions: string): string[] { - const found = new Set(); - for (const match of instructions.matchAll(/references\/[\w./-]*[\w/-]/g)) { - const path = match[0].replace(/[.]+$/, ""); - if (path.includes("..") || !/\.\w+$/.test(path)) continue; - if (packageFile(path) === null) continue; - found.add(path); - if (found.size >= METHODOLOGY_INDEX_MAX_ENTRIES) break; - } - return [...found]; -} - const planCache = new Map(); /** @@ -236,7 +376,7 @@ export function consultationMethodologyForDomains( version: skill.version, domains_without_strict_checklist: withoutChecklist, sections, - further_reading: namedReferences(instructions), + further_reading: domainTechniqueIndex(unique), truncated, }); planCache.set(key, methodology); diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 0f018e92..fd8bec79 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -167,7 +167,7 @@ export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation"; * calculation only; it never calculates. */ export const CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID = "read-consultation-evidence"; -export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 1; +export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3; export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation"; /** @@ -1230,7 +1230,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { }); const lookupTool = createTool({ id: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID, - description: `Read one section of this request's finished chart calculation that is not on the evidence card, for example a varga the card does not carry (varga:D60), a Western layer (western:solar_return), the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit, or KP (still blocked: reference only, never a basis for a conclusion). It never calculates: it returns what run-jyotish-consultation already computed, or status unavailable. At most ${MAX_EVIDENCE_LOOKUPS_PER_TURN} call per turn; a second call is refused. Call it before writing any answer text, and only when the question needs that section; the answer contract and card come from run-jyotish-consultation.`, + description: `Read one section of this request's finished chart calculation that is not on the evidence card, for example a varga the card does not carry (varga:D60), a Western layer (western:solar_return), the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit, or KP (still blocked: reference only, never a basis for a conclusion). It never calculates: it returns what run-jyotish-consultation already computed, or status unavailable. At most ${MAX_EVIDENCE_LOOKUPS_PER_TURN} calls per turn; a fourth call is refused. Call it before writing any answer text, and only when the question needs that section; the answer contract and card come from run-jyotish-consultation.`, inputSchema: evidenceLookupInputSchema, execute: async (input, context) => { const startedAt = Date.now(); diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 3e73f551..bb44d4b8 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -164,6 +164,8 @@ export async function runConsultationWorkflow( timing_horizon: workflowRequest.timing_horizon, precision_boundary: workflowRequest.precision_boundary, required_evidence_categories: workflowRequest.required_evidence_categories, + include_consult_card_facts: true, + consult_card_domains: [theme], }), signal, }); @@ -896,5 +898,8 @@ export function toAgentConsultationContext(data: JsonRecord) { real_case_calibration: record(data.real_case_calibration), }, reference_transparency: record(data.reference_transparency), + ...(record(data.consult_card_facts).schema === "consult-card-facts-v1" + ? { consult_card_facts: data.consult_card_facts } + : {}), }; } diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 757fbb99..474f5c7e 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -25,7 +25,7 @@ Call run-jyotish-consultation before answering every turn, including short follo Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. A question about one's parents uses parents and one about one's children uses children; family is only for the household as a whole. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters. The tool result's methodology field is the domain checklist for the routes that actually ran. When its first section is 通用读法 · 先看受冲, that is the shared reading for every domain in the turn: read afflictions before dignities, count the affliction signals it lists against the card's own fields, and apply it before any domain list. For career, marriage and wealth the domain section is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; parents, children, education, migration, family, general and health carry a Chinese consultation checklist written for this product; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. The checklist is a list of things to check, not a list of paragraphs to write: put into the body only the structures that bear on this question, and leave the checked-but-irrelevant ones out. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written; consultations carries each domain's own contract. Never treat an absent top-level field as permission to answer without a contract. -The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules, and d9: the D9 lagna, each planet's D9 sign and dignity, Vargottama and major D1/D9 dignity reversals — use it to confirm or weaken a D1 promise), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses; the parents section also carries roles, which says which house, lords, significator, karaka and D12 house belong to the mother and which to the father — write about each person from that person's entries). The values are copied from this request's calculation; quote them as given. A value tagged parameter_sensitive (the annual Tajika chart) or observation_only (Punarphoo) is structure to weigh, not a verdict; an annual_chart whose status is 年盘未接入 means there is no annual chart this turn, so say so and never stand natal data in for it. Chara Dasha and KP are never on the card; KP exact cusps are still blocked, so a KP lookup is reference only and never the basis of a conclusion. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, or the Moon transit for a month- or day-level question), call read-consultation-evidence once, before writing any answer text; it returns that section of this calculation or says it is unavailable, and it never calculates. Answer from the card, plus that one lookup when you used it. +The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules, and d9: the D9 lagna, each planet's D9 sign and dignity, Vargottama and major D1/D9 dignity reversals — use it to confirm or weaken a D1 promise), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses; the parents section also carries roles, which says which house, lords, significator, karaka and D12 house belong to the mother and which to the father — write about each person from that person's entries). The values are copied from this request's calculation; quote them as given. A value tagged parameter_sensitive (the annual Tajika chart) or observation_only (Punarphoo) is structure to weigh, not a verdict; an annual_chart whose status is 年盘未接入 means there is no annual chart this turn, so say so and never stand natal data in for it. Chara Dasha and KP are never on the card; KP exact cusps are still blocked, so a KP lookup is reference only and never the basis of a conclusion. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, or the Moon transit for a month- or day-level question), call read-consultation-evidence at most three times, before writing answer text; it returns that section or unavailable, and it never calculates. Answer from the card, plus those lookups when you used them. evidence_card.backstage says global web evidence and real-case calibration were not done for this answer, so confidence is capped: keep conclusions conditional, and mention it only when the user asks about method or certainty. When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage. Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text. diff --git a/frontend/tests/consult-affliction-reading-20261001.test.ts b/frontend/tests/consult-affliction-reading-20261001.test.ts index db387c23..f398b4ab 100644 --- a/frontend/tests/consult-affliction-reading-20261001.test.ts +++ b/frontend/tests/consult-affliction-reading-20261001.test.ts @@ -66,7 +66,8 @@ test("T1: the shared reading is the first section, once per turn, whatever the p // 原值: 7 行 // 新值: 8 行(第 8 行:traditional_yogas 只说明力量,不单独下结论,不算受冲也不抵消受冲) // 原因: TASK-consult-career-yoga-functional-20261002 T3 / D3 定稿,通用读法加一行(BUG-1175) - assert.equal(CONDENSED_SHARED_READING_LINES.length, 8); + // 原值(2): 8 / 新值(2): 9 / 原因(2): 综合判断表的受冲条数只是计数,并说明需要时用 skill_read 读章节(BUG-1293) + assert.equal(CONDENSED_SHARED_READING_LINES.length, 9); }); // 原值: test "T1/T2 budget blocker is pinned: a single annual or timing turn keeps its pre-brief method" @@ -170,7 +171,10 @@ test("hard red line 3: every field name the lists cite is a key on a real card", ].join("\n"); // Lower-case identifiers in these Chinese texts are field names; "blocked" is the // only prose word (KP 精确宫头仍 blocked). Static text scan, not intent detection. - const prose = new Set(["blocked"]); + // 原值: new Set(["blocked"]) + // 新值: 再加上 skill_read、further_reading + // 原因: 清单要写「用 skill_read 读 further_reading 里的章节」,这两个词是工具和目录名,不是卡上的字段 + const prose = new Set(["blocked", "skill_read", "further_reading"]); const cited = new Set([...texts.matchAll(/(? match[0]).filter((word) => !prose.has(word))); for (const required of ["conjunct_with", "aspected_by", "combust", "lord_rows", "significator_rows", "saturn_from_moon_house", "hits", "functional"]) { assert.ok(cited.has(required), `the lists cite ${required}`); diff --git a/frontend/tests/consult-card-full-source-20261007.test.ts b/frontend/tests/consult-card-full-source-20261007.test.ts new file mode 100644 index 00000000..75bbf5d8 --- /dev/null +++ b/frontend/tests/consult-card-full-source-20261007.test.ts @@ -0,0 +1,131 @@ +// 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 test from "node:test"; + +import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts"; +import { assembleJudgmentTable, JUDGMENT_TABLE_LABELS } from "../src/lib/consultation-judgment-table.ts"; + +const PRECISION = "出生时间精度不够,不参与"; + +function leaf(path: string, value: unknown) { + return { status: "value", path, value, check: "path" }; +} + +const facts = { + schema: "consult-card-facts-v1", + domain: "career", + header_note: JUDGMENT_TABLE_LABELS.rahuNote, + domain_varga: "D10", + planets: { + Sun: { + present: true, + d1_sign: leaf("core_chart.planets.Sun.sign", "Aries"), + d1_house: leaf("core_chart.planets.Sun.house", 10), + d1_dignity: leaf("core_chart.planets.Sun.status", "落陷(Debilitated)"), + rasi_lord: leaf("core_chart.planets.Sun.rasi_lord", "Mars"), + combust: leaf("core_chart.planets.Sun.combust", false), + aspected_by: { status: "gap", path: "core_chart.planets.Sun.aspected_by", check: "path" }, + functional_role: { status: "value", path: "worksheets.strengths_and_scores.functional_benefic_malefic.functional_malefics", value: "functional_malefics", check: "membership" }, + shadbala_rupas: leaf("worksheets.strengths_and_scores.shadbala.planets.Sun.total_rupas", 6.25), + shadbala_min: leaf("worksheets.strengths_and_scores.shadbala.planets.Sun.min_required", 5), + bav: { status: "value", path: "worksheets.strengths_and_scores.ashtakavarga.bav.Sun.bindus", index: 0, value: 4, check: "index" }, + d9_sign: leaf("worksheets.divisional_and_special_charts.varga_full.D9_Navamsa.Sun.sign", "Aries"), + d9_dignity: leaf("worksheets.divisional_and_special_charts.varga_full.D9_Navamsa._dignity.Sun", "Own"), + co_tenants: { status: "value", check: "selection", value: ["Saturn"] }, + domain_varga_house: { status: "withheld", value: PRECISION, check: "withheld" }, + }, + Saturn: { + present: true, + d1_sign: leaf("core_chart.planets.Saturn.sign", "Aries"), + d1_house: leaf("core_chart.planets.Saturn.house", 10), + d1_dignity: leaf("core_chart.planets.Saturn.status", "中性(Neutral)"), + functional_role: { status: "value", path: "functional.functional_benefics", value: "functional_benefics", check: "membership" }, + co_tenants: { status: "value", check: "selection", value: ["Sun"] }, + domain_varga_house: { status: "gap", check: "path" }, + }, + Rahu: { + present: true, + d1_sign: leaf("core_chart.planets.Rahu.sign", "Leo"), + d1_house: leaf("core_chart.planets.Rahu.house", 1), + d1_dignity: leaf("core_chart.planets.Rahu.status", "中性(Neutral)"), + rasi_lord: leaf("core_chart.planets.Rahu.rasi_lord", "Sun"), + functional_role: { status: "value", path: "functional", value: null, check: "node_role", note: JUDGMENT_TABLE_LABELS.rahuNote }, + co_tenants: { status: "value", check: "selection", value: ["Mars"] }, + aspected_by: leaf("core_chart.planets.Rahu.aspected_by", ["Saturn"]), + domain_varga_house: { status: "gap", check: "path" }, + }, + }, + houses: { + "10": { + sign: leaf("core_chart.houses.house_10.cusp_sign", "Aries"), + lord: leaf("core_chart.houses.house_10.lord", "Sun"), + sav_score: leaf("worksheets.strengths_and_scores.ashtakavarga.house_scores.house_10.sav_score", 30), + sav_sodhita: leaf("worksheets.strengths_and_scores.ashtakavarga.house_scores.house_10.sav_sodhita", 28), + occupants: { status: "value", check: "selection", value: ["Sun", "Saturn"] }, + }, + }, + factors: [ + { id: "house:10", kind: "house", house: 10, citation: "references/event_judgment_career.md §2 10H" }, + { id: "natural:Saturn", kind: "planet", planet: "Saturn", citation: "references/event_judgment_career.md §2 Saturn", role: "natural" }, + { id: "natural:Rahu", kind: "planet", planet: "Rahu", citation: "references/house-domain-planet-mapping.md", role: "natural" }, + { id: "jaimini", kind: "blocked", citation: "references/jaimini-complete-system.md 未给出" }, + ], + participation: [ + { id: "D24", status: "withheld", value: PRECISION, check: "withheld", citation: "references/strict-workflow-router.md" }, + ], +}; + +test("the judgment table copies leaves and does not turn the count into a verdict", () => { + const table = assembleJudgmentTable(facts); + const encoded = JSON.stringify(table); + assert.equal(encoded.includes("threshold"), false); + assert.equal(encoded.includes("两个及以上"), false); + assert.equal(table.header_note, JUDGMENT_TABLE_LABELS.rahuNote); + const rows = table.rows as Array>; + const lord = rows.find((row) => row.id === "house:10:lord") as Record; + assert.equal(lord.planet, "Sun"); + assert.equal((lord.d1_sign as { value: string }).value, "Aries"); + assert.equal((lord.d1_house as { value: number }).value, 10); + assert.equal((lord.d1_dignity as { value: string }).value, "落陷(Debilitated)"); + assert.equal((lord.shadbala_rupas as { value: number }).value, 6.25); + assert.equal((lord.shadbala_min as { value: number }).value, 5); + assert.equal((lord.bav as { value: number }).value, 4); + assert.equal((lord.vargottama as { value: boolean }).value, true); + assert.deepEqual((lord.vargottama as { derived_from: string[] }).derived_from, [ + "core_chart.planets.Sun.sign", + "worksheets.divisional_and_special_charts.varga_full.D9_Navamsa.Sun.sign", + ]); + assert.equal((lord.domain_varga_house as { value: string }).value, PRECISION); + const relations = lord.relations as Array<{ house: number; labels: string[] }>; + assert.deepEqual(relations[0]?.labels, [JUDGMENT_TABLE_LABELS.relationLord, JUDGMENT_TABLE_LABELS.relationOccupant]); + assert.deepEqual(lord.relation_gaps, [JUDGMENT_TABLE_LABELS.relationAspect]); + const affliction = lord.affliction as { count: number; details: Array<{ signal: number }> }; + assert.equal(typeof affliction.count, "number"); + assert.ok(affliction.details.some((detail) => detail.signal === 1)); + assert.ok(affliction.details.some((detail) => detail.signal === 4)); + assert.equal(affliction.details.some((detail) => detail.signal === 5), false); + const rahu = rows.find((row) => row.planet === "Rahu") as Record; + assert.equal((rahu.functional_role as { value: null }).value, null); + assert.equal((rahu.rasi_lord as { value: string }).value, "Sun"); + assert.deepEqual((rahu.co_tenants as { value: string[] }).value, ["Mars"]); + const blocked = rows.find((row) => row.id === "jaimini"); + assert.equal(blocked?.kind, "blocked"); +}); + +test("a card without facts keeps the old section, and facts attach one table", () => { + const bare = buildEvidenceCard([{ domain: "career", packet: { claim_cards: [] }, context: {} }]); + assert.equal(bare.domain_sections.career?.judgment_table, undefined); + assert.equal(bare.card_version, "evidence-card-v2"); + const withFacts = buildEvidenceCard([{ + domain: "career", + packet: { claim_cards: [] }, + context: { consult_card_facts: { schema: "consult-card-facts-v1", domains: [facts] } }, + }]); + const section = withFacts.domain_sections.career; + assert.equal(section?.judgment_table?.header_note, JUDGMENT_TABLE_LABELS.rahuNote); + assert.equal((section?.participation as Array<{ value: string }>)[0]?.value, PRECISION); + const encoded = JSON.stringify(section); + assert.equal(encoded.includes("threshold"), false); + assert.equal(encoded.includes("两个及以上"), false); +}); diff --git a/frontend/tests/consult-condensed-checklist-20260927.test.ts b/frontend/tests/consult-condensed-checklist-20260927.test.ts index c08c583f..e1461032 100644 --- a/frontend/tests/consult-condensed-checklist-20260927.test.ts +++ b/frontend/tests/consult-condensed-checklist-20260927.test.ts @@ -8,8 +8,9 @@ // golden engine capture of public AA charts (AGENTS §7.4). import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { CONDENSED_CHECKLIST_SOURCE, @@ -150,14 +151,18 @@ test("other routes still quote the live skill, and the bound system method is un }); test("only the consultation tool builds the methodology: reports and other surfaces never read it", () => { - const root = new URL("../src/", import.meta.url).pathname; + // URL.pathname on Windows is "/G:/..." and readdirSync joins that into "G:\\G:\\...". + // fileURLToPath is the path fs expects. Expected relative names are unchanged. + const rootUrl = fileURLToPath(new URL("../src/", import.meta.url)); + const root = rootUrl.endsWith(sep) ? rootUrl : rootUrl + sep; + const relative = (path: string) => path.slice(root.length).replaceAll("\\", "/"); const users: string[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) walk(path); else if (/\.(ts|tsx)$/.test(entry.name) && readFileSync(path, "utf8").includes("consultationMethodologyForDomains")) { - users.push(path.slice(root.length)); + users.push(relative(path)); } } }; @@ -169,7 +174,7 @@ test("only the consultation tool builds the methodology: reports and other surfa const path = join(dir, entry.name); if (entry.isDirectory()) walkCondensed(path); else if (/\.(ts|tsx)$/.test(entry.name) && readFileSync(path, "utf8").includes("consultation-condensed-checklist")) { - condensedUsers.push(path.slice(root.length)); + condensedUsers.push(relative(path)); } } }; @@ -206,10 +211,12 @@ test("size: single-domain turns stay within 12,500 characters with the checklist assert.equal(state.modelVisibleChars, total); if (domain === "annual" || domain === "timing") { const contractAndCard = JSON.stringify({ ...(view as Json), methodology: undefined }).length; - assert.ok(contractAndCard <= 12_500, `${chart.id}/${domain}: contract + card ${contractAndCard}`); - assert.ok(total <= 18_500, `${chart.id}/${domain}: ${total}`); + // 原值: 12_500 / 18_500 / 新值: 20_000 / 26_000 + // 原因: 产品 2026-10-08 临时上限(推翻 BUG-1160/1161/1221) + assert.ok(contractAndCard <= 20_000, `${chart.id}/${domain}: contract + card ${contractAndCard}`); + assert.ok(total <= 26_000, `${chart.id}/${domain}: ${total}`); } else { - assert.ok(total <= 12_500, `${chart.id}/${domain}: ${total}`); + assert.ok(total <= 20_000, `${chart.id}/${domain}: ${total}`); } } } diff --git a/frontend/tests/consult-evidence-card-20260927.test.ts b/frontend/tests/consult-evidence-card-20260927.test.ts index 2c77ae37..ca2cf4c9 100644 --- a/frontend/tests/consult-evidence-card-20260927.test.ts +++ b/frontend/tests/consult-evidence-card-20260927.test.ts @@ -290,9 +290,11 @@ test("model-visible size falls to card scale; only the method checklist can push const { methodology, ...rest } = view; const withoutMethod = JSON.stringify(rest).length; assert.equal(state.modelVisibleChars, total); - assert.ok(withoutMethod <= 12_500, `${chart.id}/${domain}: contract + card ${withoutMethod}`); - if (!methodHeavy.has(domain)) assert.ok(total <= 12_500, `${chart.id}/${domain}: ${total}`); - assert.ok(total <= 18_500, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`); + // 原值: 12_500 / 18_500 / 新值: 20_000 / 26_000 + // 原因: 产品 2026-10-08 临时上限,最终上限等实测后再定(推翻 BUG-1160/1161/1221 的 12,500/18,500) + assert.ok(withoutMethod <= 20_000, `${chart.id}/${domain}: contract + card ${withoutMethod}`); + if (!methodHeavy.has(domain)) assert.ok(total <= 20_000, `${chart.id}/${domain}: ${total}`); + assert.ok(total <= 26_000, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`); } } }); diff --git a/frontend/tests/consult-evidence-card-v2-20260927.test.ts b/frontend/tests/consult-evidence-card-v2-20260927.test.ts index ce8cdf42..6e9097ea 100644 --- a/frontend/tests/consult-evidence-card-v2-20260927.test.ts +++ b/frontend/tests/consult-evidence-card-v2-20260927.test.ts @@ -360,7 +360,8 @@ test("size: contract + card stay under 12,500 characters without the checklist, const view = await tools["run-jyotish-consultation"].execute!({ question: "q", domains: [domain] } as never, { writer: { custom: async () => {} } } as never) as Json; const { methodology: _methodology, ...rest } = view; void _methodology; - assert.ok(JSON.stringify(rest).length <= 12_500, `${chart.id}/${domain} contract + card ${JSON.stringify(rest).length}`); + // 原值: 12_500 / 新值: 20_000 / 原因: 产品 2026-10-08 临时卡片上限 + assert.ok(JSON.stringify(rest).length <= 20_000, `${chart.id}/${domain} contract + card ${JSON.stringify(rest).length}`); assert.deepEqual(dig(view, "evidence_card", "gaps") ?? [], [], `${chart.id}/${domain} gaps`); } } diff --git a/frontend/tests/consult-evidence-lookup-20260927.test.ts b/frontend/tests/consult-evidence-lookup-20260927.test.ts index 9c8bc037..535a22ec 100644 --- a/frontend/tests/consult-evidence-lookup-20260927.test.ts +++ b/frontend/tests/consult-evidence-lookup-20260927.test.ts @@ -6,7 +6,8 @@ // charged; a length continuation carries the card. // // T5 (D8): one read-only lookup of a section outside the card, from this -// request's calculation only, at most once per turn. A lookup during the +// request's calculation only. Product 2026-10-08: at most three per turn. +// A lookup during the // answer phase must not cut or repeat released text and must not reset the // 110 s tool / 70 s answer clocks. // @@ -145,7 +146,8 @@ test("a length continuation carries the card (red line 4)", async () => { test("step 0 still exposes only the calculation tool; later steps may use the lookup", () => { assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }).activeTools, ["run-jyotish-consultation"]); assert.equal("activeTools" in consultationNatalPrepareStep({ stepNumber: 1 }), false); - assert.equal(MAX_EVIDENCE_LOOKUPS_PER_TURN, 1); + // 原值: 1 / 新值: 3 / 原因: 产品 2026-10-08 把卡外补取上限从 1 改为 3,第 4 次仍拒绝 + assert.equal(MAX_EVIDENCE_LOOKUPS_PER_TURN, 3); }); test("a lookup before the answer returns the section from the request cache and the answer is written once", async () => { @@ -173,20 +175,29 @@ test("a lookup before the answer returns the section from the request cache and assert.equal(result.charges, 1); }); -test("a second lookup in the same turn is refused", async () => { +// 原值: 同轮第二次补取被拒绝,evidenceLookupCallCount === 2,步骤 completed/failed +// 新值: 前三次返回该节,第四次 lookup_limit_reached +// 原因: 产品 2026-10-08 把卡外补取上限从 1 改为 3(BUG-1294) +test("the third lookup in the same turn succeeds and the fourth is refused", async () => { const result = await run([ calcCall(), lookupCall("varga:D60"), - lookupCall("western:solar_return"), + lookupCall("varga:D60"), + lookupCall("varga:D60"), + lookupCall("varga:D60"), { parts: pieces(ANSWER), finish: "stop" }, ]); assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]); - const second = toolResultText(result.prompts[3], CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID); - assert.ok(second.includes("lookup_limit_reached")); - assert.equal(result.state.evidenceLookupCallCount, 2); + const lookups = result.prompts + .map((prompt) => toolResultText(prompt, CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID)) + .filter((text) => text.includes(CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID)); + // Later prompts keep earlier tool results, so "ok" stays visible after the refusal. + assert.ok(lookups.slice(0, 3).every((text) => /status\\*"\s*:\s*\\*"ok/.test(text))); + assert.ok(lookups.at(-1)?.includes("lookup_limit_reached")); + assert.equal(result.state.evidenceLookupCallCount, 4); assert.deepEqual( result.state.steps.filter((item) => item.name === CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID).map((item) => item.status), - ["completed", "failed"], + ["completed", "completed", "completed", "failed"], ); assert.equal(result.answer, ANSWER); }); diff --git a/frontend/tests/consultation-methodology.test.ts b/frontend/tests/consultation-methodology.test.ts index 7e2bd139..3a6458df 100644 --- a/frontend/tests/consultation-methodology.test.ts +++ b/frontend/tests/consultation-methodology.test.ts @@ -140,22 +140,22 @@ test("the widest legal plan still fits the budget", () => { assert.ok(total <= METHODOLOGY_TOTAL_MAX_CHARS, `delivered ${total} chars`); }); -test("further reading offers the references the skill names, and only ones that exist", () => { - const methodology = consultationMethodologyForDomains(["career"]); - assert.ok(methodology); - assert.ok(methodology.further_reading.length > 0, "the skill names references worth reading"); - assert.ok( - methodology.further_reading.includes("references/strict-workflow-router.md"), - "the router the skill points at must be reachable on purpose", - ); - for (const path of methodology.further_reading) { - assert.match(path, /^references\//); - assert.ok(!path.includes(".."), "no path may escape the package"); +// 原值: further_reading 是技能点名的 references/ 路径,含 strict-workflow-router.md,长度 < 100 +// 新值: 每领域 `domain: references/...`,1 到 8 条,路径在技能包里,不含 oracle / governance +// 原因: 产品 2026-10-08 把资料目录改成按领域的技法文件(BUG-1295) +test("further reading is a per-domain technique index of files that exist", () => { + for (const domain of consultationDomainIds) { + const methodology = consultationMethodologyForDomains([domain]); + assert.ok(methodology, domain); + const rows = methodology.further_reading; + assert.ok(rows.length > 0 && rows.length <= 8, `${domain} ${rows.length}`); + for (const row of rows) { + assert.match(row, new RegExp(`^${domain}: references\\/`)); + assert.equal(row.includes("/oracle/"), false); + assert.equal(row.toLowerCase().includes("governance"), false); + assert.equal(row.includes(".."), false); + } } - assert.ok( - methodology.further_reading.length < 100, - "the index exists to replace the package listing, not to reproduce it", - ); }); test("the delivered method is quoted from the live skill tree", () => { diff --git a/frontend/tests/consultation-voice-contract.test.ts b/frontend/tests/consultation-voice-contract.test.ts index 23822b8f..90444828 100644 --- a/frontend/tests/consultation-voice-contract.test.ts +++ b/frontend/tests/consultation-voice-contract.test.ts @@ -67,7 +67,10 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton" // 新值: 数据卡是这次回答的清单;卡外数据只能用补取工具取一次 // 原因: TASK-consult-evidence-card-20260927 D1/D3/T4,must_use_layers 与审计表不再进模型上下文 assert.match(mastra, /The evidence card is the shortlist for this answer/); - assert.match(mastra, /call read-consultation-evidence once, before writing any answer text/); + // 原值: /call read-consultation-evidence once, before writing any answer text/ + // 新值: at most three times, before writing answer text + // 原因: 产品 2026-10-08 把卡外补取从 1 次改为最多 3 次;这句比原句短 20 字符,系统提示不增长(BUG-1256) + assert.match(mastra, /call read-consultation-evidence at most three times, before writing answer text/); assert.doesNotMatch(mastra, /must_use_layers is the executed shortlist|Use every executed layer/); assert.match(mastra, /do not invent it from model knowledge/); assert.match(mastra, /search_period is the searched observation window/); diff --git a/scripts/consult_card_domain_facts.py b/scripts/consult_card_domain_facts.py new file mode 100644 index 00000000..dfc614f9 --- /dev/null +++ b/scripts/consult_card_domain_facts.py @@ -0,0 +1,824 @@ +"""Copy consult-card facts from the full-data packet. + +Values are packet leaves. A missing leaf is a gap. A technique with no +``references/`` heading is blocked. D16 and finer, plus Ghati, stay off the +card unless the birth minute is confirmed. This module does not assemble the +judgment table; the card builder does that from these leaves. +""" + +from __future__ import annotations + +import json +from typing import Any + +from pl9_full_data_export import read_full_data_domain + +PRECISION_SENTENCE = "出生时间精度不够,不参与" +RAHU_NOTE = "罗计不单独定功能吉凶" +CONSULTATION_DOMAINS = ( + "career", "marriage", "wealth", "health", "education", "migration", + "family", "parents", "children", "annual", "timing", "general", +) +PACKET_DOMAIN = { + "career": "career", + "marriage": "relationship", + "wealth": "wealth", + "health": "health", + "education": "education", + "migration": "relocation", + "family": "family", + "parents": "parents", + "children": "children", + "annual": "annual", + "timing": "timing", + "general": "general", +} +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +VARGA_KEYS = { + "D2": ("D2_Hora",), + "D3": ("D3_Drekkana",), + "D4": ("D4_Turyamsa", "D4_Chaturthamsa"), + "D5": ("D5_Panchamsa",), + "D6": ("D6_Shashthamsa",), + "D7": ("D7_Saptamsa",), + "D8": ("D8_Ashtamsa",), + "D9": ("D9_Navamsa",), + "D10": ("D10_Dasamsa",), + "D11": ("D11_Rudramsa",), + "D12": ("D12_Dwadashamsa",), + "D16": ("D16_Shodasamsa",), + "D20": ("D20_Vimsamsa",), + "D24": ("D24_Siddhamsa", "D24_Chaturvimsamsa"), + "D27": ("D27_Bhamsa",), + "D30": ("D30_Trimsamsa",), + "D40": ("D40_Khavedamsa",), + "D45": ("D45_Akshavedamsa",), + "D60": ("D60_Shashtyamsa",), +} +EXTENDED_VARGA = {"D5": "Panchamsa", "D6": "Shashthamsa", "D8": "Ashtamsa", "D11": "Rudramsa"} +PRECISION_VARGA = {"D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"} +ROUTER = "references/strict-workflow-router.md" +MAPPING = "references/house-domain-planet-mapping.md" +CAREER = "references/event_judgment_career.md" +MARRIAGE = "references/event_judgment_marriage.md" +WEALTH = "references/event_judgment_wealth.md" +JAIMINI = "references/jaimini-complete-system.md" +DEEP = "references/deep-analysis-complete-workflow.md" +VARSHAPHALA = "references/varshaphala-annual-chart-guide.md" +PDF = "references/pdf-chart-reading-guide.md" +RECTIFY = "references/birth-time-rectification-decision-tree.md" +SHODASA = "references/shodasavarga-complete-guide.md" +BADHAKA = "references/badhaka-obstacle-planet-guide.md" +NARAYANA = "references/bphs-ch48-narayana-dasha.md" +VIM = "references/vimshottari_dasha_guide.md" + + +def dig(node: Any, path: str) -> Any: + current = node + for part in path.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + elif isinstance(current, list) and part.isdigit(): + index = int(part) + if index < 0 or index >= len(current): + return None + current = current[index] + else: + return None + return current + + +def _gap(path: str | None = None) -> dict[str, Any]: + item: dict[str, Any] = {"status": "gap", "check": "path"} + if path: + item["path"] = path + return item + + +def _leaf(view: dict, path: str) -> dict[str, Any]: + value = dig(view, path) + if value is None or value == "" or value == {} or value == []: + return _gap(path) + if isinstance(value, (str, int, float, bool)): + return {"status": "value", "path": path, "value": value, "check": "path"} + encoded = json.dumps(value, ensure_ascii=False, default=str) + if "tajika_named_yogas" in encoded or len(encoded) > 800: + return {"status": "value", "path": path, "check": "nonempty"} + return {"status": "value", "path": path, "value": value, "check": "path"} + + +def _first(view: dict, paths: list[str]) -> dict[str, Any]: + seen = paths[0] if paths else None + for path in paths: + item = _leaf(view, path) + if item["status"] == "value": + return item + seen = path + return _gap(seen) + + +def minute_confirmed_from_body(body: dict | None) -> bool: + """Same gate as persistedConsultationMode's verified_chart, on the raw body. + + ``birth_time_accuracy`` counts only when the request set it. The API's + internal default of confirmed is not a confirmation. + """ + payload = body or {} + if payload.get("hour") is None or payload.get("minute") is None: + return False + status = payload.get("birth_time_status") + if status in ("accepted", "confirmed"): + return True + if "birth_time_accuracy" in payload and payload.get("birth_time_accuracy") == "confirmed": + return True + # `rectified` is the profile map for an accepted or confirmed minute. + # `minute` also covers a reported clock that is not accepted, and that + # clock still withholds D16 and Ghati. + if payload.get("declared_accuracy") == "rectified": + return True + return False + + +def _planet_base(view: dict, name: str) -> str | None: + for path in ( + f"core_chart.planets.{name}", + f"worksheets.d1_rasi_bhava.planets.{name}", + ): + node = dig(view, path) + if isinstance(node, dict) and node.get("sign"): + return path + return None + + +def _functional_role(view: dict, name: str) -> dict[str, Any]: + base = "worksheets.strengths_and_scores.functional_benefic_malefic" + if name in ("Rahu", "Ketu"): + return {"status": "value", "path": base, "value": None, "check": "node_role", "note": RAHU_NOTE} + table = dig(view, base) + if not isinstance(table, dict): + return _gap(base) + for key in ("functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas"): + names = table.get(key) + if isinstance(names, list) and name in names: + return {"status": "value", "path": f"{base}.{key}", "value": key, "check": "membership", "member": name} + return {"status": "value", "path": base, "value": None, "check": "node_role", "note": RAHU_NOTE} + + +def _bav(view: dict, name: str, sign_idx: Any) -> dict[str, Any]: + path = f"worksheets.strengths_and_scores.ashtakavarga.bav.{name}.bindus" + bindus = dig(view, path) + if not isinstance(bindus, list) or not isinstance(sign_idx, int): + return _gap(path) + if sign_idx < 0 or sign_idx >= len(bindus): + return _gap(path) + return {"status": "value", "path": path, "index": sign_idx, "value": bindus[sign_idx], "check": "index"} + + +def _varga_chart(view: dict, code: str) -> tuple[Any, str | None]: + for key in VARGA_KEYS.get(code, ()): + path = f"worksheets.divisional_and_special_charts.varga_full.{key}" + node = dig(view, path) + if isinstance(node, dict) and node: + return node, path + extended = EXTENDED_VARGA.get(code) + if extended: + path = f"worksheets.divisional_and_special_charts.varga_extended.{extended}" + node = dig(view, path) + if isinstance(node, dict) and node: + return node, path + return None, None + + +def _varga_planet_path(chart_path: str, chart: dict, name: str) -> str | None: + direct = chart.get(name) + if isinstance(direct, dict): + return f"{chart_path}.{name}" + planets = chart.get("planets") + if isinstance(planets, dict) and isinstance(planets.get(name), dict): + return f"{chart_path}.planets.{name}" + return None + + +def _domain_varga_house(view: dict, name: str, code: str, minute_ok: bool) -> dict[str, Any]: + if code in PRECISION_VARGA and not minute_ok: + return {"status": "withheld", "value": PRECISION_SENTENCE, "check": "withheld"} + chart, chart_path = _varga_chart(view, code) + if chart_path: + planet_path = _varga_planet_path(chart_path, chart, name) + if planet_path: + house = _leaf(view, f"{planet_path}.house") + if house["status"] == "value": + return house + linked = _leaf(view, f"worksheets.advanced_systems.inter_chart_linkage.{name}.{code}.house") + if linked["status"] == "value": + return linked + return _gap(f"varga.{code}.{name}.house") + + +def _d9(view: dict, name: str) -> tuple[dict[str, Any], dict[str, Any]]: + chart, chart_path = _varga_chart(view, "D9") + if not chart_path: + return _gap("varga.D9"), _gap("varga.D9.dignity") + planet_path = _varga_planet_path(chart_path, chart, name) + sign = _leaf(view, f"{planet_path}.sign") if planet_path else _gap(f"{chart_path}.{name}.sign") + dignity = _leaf(view, f"{chart_path}._dignity.{name}") + if dignity["status"] != "value" and planet_path: + dignity = _leaf(view, f"{planet_path}.dignity") + return sign, dignity + + +def _co_tenants(view: dict, name: str, house: Any) -> dict[str, Any]: + if not isinstance(house, int): + return {"status": "gap", "check": "selection"} + names = [] + for other in PLANETS: + if other == name: + continue + base = _planet_base(view, other) + if base and dig(view, f"{base}.house") == house: + names.append(other) + return {"status": "value", "check": "selection", "value": names} + + +def _planet_record(view: dict, name: str, varga: str | None, minute_ok: bool) -> dict[str, Any]: + base = _planet_base(view, name) + if not base: + return {"present": False} + sign_idx = dig(view, f"{base}.sign_idx") + house = dig(view, f"{base}.house") + d9_sign, d9_dignity = _d9(view, name) + record = { + "present": True, + "d1_sign": _leaf(view, f"{base}.sign"), + "d1_house": _leaf(view, f"{base}.house"), + "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"), + "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"), + "bav": _bav(view, name, sign_idx), + "d9_sign": d9_sign, + "d9_dignity": d9_dignity, + "co_tenants": _co_tenants(view, name, house), + } + if varga: + record["domain_varga_house"] = _domain_varga_house(view, name, varga, minute_ok) + return record + + +def _house_record(view: dict, number: int) -> dict[str, Any]: + base = f"core_chart.houses.house_{number}" + if not isinstance(dig(view, base), dict): + base = f"worksheets.d1_rasi_bhava.houses.house_{number}" + occupants = [] + for name in PLANETS: + planet_base = _planet_base(view, name) + if planet_base and dig(view, f"{planet_base}.house") == number: + occupants.append(name) + return { + "sign": _leaf(view, f"{base}.cusp_sign"), + "lord": _leaf(view, f"{base}.lord"), + "sav_score": _leaf(view, f"worksheets.strengths_and_scores.ashtakavarga.house_scores.house_{number}.sav_score"), + "sav_sodhita": _leaf(view, f"worksheets.strengths_and_scores.ashtakavarga.house_scores.house_{number}.sav_sodhita"), + "bhava_bala": _first(view, [ + f"worksheets.strengths_and_scores.bhava_bala.{number}", + f"worksheets.strengths_and_scores.shadbala.bhava_bala.{number}", + ]), + "occupants": {"status": "value", "check": "selection", "value": occupants}, + } + + +def _current_period_lord(view: dict, direct: str, timeline: str) -> dict[str, Any]: + found = _leaf(view, direct) + if found["status"] == "value": + return found + rows = dig(view, timeline) + if isinstance(rows, list): + for index, row in enumerate(rows): + if isinstance(row, dict) and row.get("is_current") is True and isinstance(row.get("lord"), str): + return {"status": "value", "path": f"{timeline}.{index}.lord", "value": row["lord"], "check": "path"} + return _gap(direct) + + +def _timing(view: dict) -> dict[str, Any]: + dasha = "worksheets.timing_and_predictive_systems.dasha.current_dasha" + ad = _current_period_lord(view, f"{dasha}.antardasha.lord", f"{dasha}.antardasha_timeline") + pd = _gap(f"{dasha}.pratyantar.lord") + rows = dig(view, f"{dasha}.antardasha_timeline") + if isinstance(rows, list): + for index, row in enumerate(rows): + if not (isinstance(row, dict) and row.get("is_current") is True): + continue + nested = row.get("pratyantar_dasha_timeline") + if isinstance(nested, list): + for sub, item in enumerate(nested): + if isinstance(item, dict) and item.get("is_current") is True and isinstance(item.get("lord"), str): + pd = { + "status": "value", + "path": f"{dasha}.antardasha_timeline.{index}.pratyantar_dasha_timeline.{sub}.lord", + "value": item["lord"], + "check": "path", + } + break + narayana = "worksheets.timing_and_predictive_systems.narayana_dasha.current_dasha" + return { + "md": _leaf(view, f"{dasha}.lord"), + "ad": ad, + "pd": pd, + "narayana_sign": _leaf(view, f"{narayana}.md.sign"), + "narayana_lord": _leaf(view, f"{narayana}.md.lord"), + "narayana_ad_sign": _leaf(view, f"{narayana}.ad.sign"), + "narayana_ad_lord": _leaf(view, f"{narayana}.ad.lord"), + } + + +def _karaka(view: dict, table: str, role: str) -> dict[str, Any]: + key = "karaka_table" if table == "7" else "karaka_table_8" + folder = "chara_karaka_7" if table == "7" else "chara_karaka_8" + return _leaf(view, f"worksheets.advanced_systems.jaimini.{folder}.{key}.{role}.planet") + + +def _point(view: dict, paths: list[str], *, precision: bool, minute_ok: bool) -> dict[str, Any]: + if precision and not minute_ok: + return {"status": "withheld", "value": PRECISION_SENTENCE, "check": "withheld", "path": paths[0] if paths else None} + return _first(view, paths) + + +def _blocked(item_id: str, citation: str) -> dict[str, Any]: + return {"id": item_id, "status": "blocked", "citation": citation, "check": "blocked"} + + +def _sourced(item_id: str, citation: str, leaf: dict[str, Any]) -> dict[str, Any]: + return {"id": item_id, "citation": citation, **leaf} + + +# Static §3A catalog. blocked items have no packet path. sourced items name a file. +def domain_source_catalog() -> dict[str, list[dict[str, str]]]: + shared = [ + ("sudarshana", "sourced", f"{ROUTER} Sudarshana Chakra"), + ("d9", "sourced", f"{ROUTER} §2 D9 Navamsa"), + ("bhava_chalit", "sourced", f"{ROUTER} Full Bhava Chalit"), + ("shadbala", "sourced", f"{ROUTER} §2 Shadbala"), + ("bhava_bala", "sourced", f"{ROUTER} §2 Shadbala"), + ("avastha", "sourced", "references/deep-varga-avastha-execution-guide.md"), + ("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 星照单独列为参与层的段落"), + ("jaimini_aspects", "sourced", f"{JAIMINI} Jaimini Drishti"), + ("argala", "sourced", "references/argala-complete-guide.md"), + ("dispositor_chain", "sourced", f"{ROUTER} §2 dispositor chain"), + ("vimshottari", "sourced", VIM), + ("narayana", "sourced", NARAYANA), + ("yogini", "sourced", "references/alternative-dasha-systems.md"), + ("transits", "sourced", f"{ROUTER} §2 Transit"), + ("double_transit", "sourced", f"{CAREER} §2 Transit / Double Transit"), + ("yogas_hits", "sourced", "references/event_judgment_skeleton.md"), + ("kp", "sourced", "决策 8:KP 不参与"), + ] + extra = { + "career": [ + ("D10", "sourced", f"{ROUTER} §2 D10 for career"), + ("A10", "sourced", f"{CAREER} §2 A10 / Karma Pada"), + ("AL", "sourced", f"{PDF} Arudha Lagna"), + ("AmK", "sourced", f"{JAIMINI} Amatyakaraka"), + ("Karakamsha", "sourced", f"{CAREER} §2 Karakamsha"), + ("Ghati", "sourced", f"{DEEP} Ghati Lagna 权力指标"), + ("Vighati", "blocked", "references/ 未找到 Vighati 条目"), + ("natural:Sun", "sourced", f"{CAREER} §2 Sun"), + ("natural:Saturn", "sourced", f"{CAREER} §2 Saturn"), + ("natural:Mercury", "sourced", f"{CAREER} §2 Mercury"), + ("natural:Jupiter", "sourced", f"{CAREER} §2 Jupiter"), + ("house:10", "sourced", f"{CAREER} §2 10H"), + ("D1_D9_D10", "sourced", f"{ROUTER} §2 D10 for career"), + ], + "wealth": [ + ("D2", "sourced", f"{WEALTH} §3 D2"), + ("D11", "sourced", f"{WEALTH} §3 D11"), + ("Hora", "sourced", f"{DEEP} Hora Lagna 财富指标"), + ("Indu", "blocked", "references/ 未找到 Indu Lagna 财富条目"), + ("Sree", "sourced", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna"), + ("AL2", "blocked", f"{WEALTH} §3 未列出从 AL 起第 2 宫"), + ("AL11", "blocked", f"{WEALTH} §3 未列出从 AL 起第 11 宫"), + ("natural:Jupiter", "sourced", f"{WEALTH} §3 Jupiter"), + ("natural:Venus", "sourced", f"{WEALTH} §3 Venus"), + ("natural:Mercury", "sourced", f"{WEALTH} §3 Mercury"), + ("house:2", "sourced", f"{WEALTH} §3 2H"), + ("house:11", "sourced", f"{WEALTH} §3 11H"), + ("house:5", "sourced", f"{WEALTH} §3 5H"), + ("house:9", "sourced", f"{WEALTH} §3 9H"), + ("house:10", "sourced", f"{WEALTH} §3 10H"), + ], + "marriage": [ + ("D9_7", "sourced", f"{MARRIAGE} §2 D9 7H / 7L"), + ("UL", "sourced", f"{MARRIAGE} §2 UL"), + ("UL2", "blocked", f"{MARRIAGE} §2 未列出 UL 起第 2 宫"), + ("A7", "blocked", f"{MARRIAGE} §2 未列出 A7"), + ("DK", "sourced", f"{JAIMINI} Darakaraka"), + ("natural:Venus", "sourced", f"{MARRIAGE} §2 Venus"), + ("natural:Jupiter", "sourced", f"{MARRIAGE} §2 Jupiter"), + ("Vivah", "sourced", f"{MARRIAGE} §2 Vivah Saham"), + ("Mars", "sourced", f"{MARRIAGE} §2 Mars"), + ("house:7", "sourced", f"{MARRIAGE} §2 7H"), + ], + "health": [ + ("D6", "sourced", f"{ROUTER} §7 D6"), + ("D8", "sourced", f"{ROUTER} §7 D8"), + ("D30", "sourced", f"{ROUTER} §7 D30"), + ("drekkana22", "sourced", f"{SHODASA} 第22Drekkana与寿限"), + ("navamsa64", "blocked", "references/ 未找到第 64 个 Navamsa 条目"), + ("Badhaka", "sourced", BADHAKA), + ("Gulika", "blocked", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"), + ("Mandi", "blocked", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"), + ("Pranapada", "blocked", "references/prashna-complete-guide.md 的 Pranapada 是问事替代计算,未列入本命健康参与层"), + ("natural:Sun", "sourced", f"{ROUTER} §7 Sun"), + ("natural:Moon", "sourced", f"{ROUTER} §7 Moon"), + ("natural:Saturn", "sourced", f"{ROUTER} §7 Saturn"), + ("natural:Mars", "sourced", f"{ROUTER} §7 Mars"), + ("house:1", "sourced", f"{ROUTER} §7 Lagna"), + ("house:6", "sourced", f"{ROUTER} §7 6H"), + ("house:8", "sourced", f"{ROUTER} §7 8H"), + ("house:12", "sourced", f"{ROUTER} §7 12H"), + ], + "children": [ + ("D7", "sourced", f"{ROUTER} §2 D7 for children"), + ("PK", "sourced", f"{JAIMINI} Putrakaraka"), + ("natural:Jupiter", "sourced", f"{MAPPING} 子女 木星"), + ("house:5", "sourced", f"{MAPPING} 子女 5宫"), + ], + "parents": [ + ("D12", "sourced", f"{ROUTER} §2 D12 for family/ancestral themes"), + ("MK", "sourced", f"{JAIMINI} Matrikaraka"), + ("PiK", "sourced", f"{JAIMINI} 可选PiK"), + ("natural:Sun", "sourced", f"{MAPPING} 父亲 太阳"), + ("natural:Moon", "sourced", f"{MAPPING} 母亲 月亮"), + ("house:4", "sourced", f"{MAPPING} 母亲 4宫"), + ("house:9", "sourced", f"{MAPPING} 父亲 9宫"), + ], + "education": [ + ("D24", "sourced", f"{ROUTER} §2 D24 for education"), + ("D5", "blocked", "references/ 未找到把 D5 标成学业分盘的段落"), + ("natural:Mercury", "sourced", f"{MAPPING} 教育 水星"), + ("natural:Jupiter", "sourced", f"{MAPPING} 教育 木星"), + ("house:4", "sourced", f"{MAPPING} 教育(基础教育)4宫"), + ("house:5", "sourced", f"{MAPPING} 智力 5宫"), + ("house:9", "sourced", f"{MAPPING} 高等教育 9宫"), + ], + "migration": [ + ("D4", "sourced", f"{RECTIFY} 房产/搬迁 D4"), + ("D12", "blocked", f"{ROUTER} §2 把 D12 标给家族,未标给迁居"), + ("natural:Rahu", "sourced", f"{MAPPING} 罗睺 外国 / 海外发展"), + ("house:4", "sourced", f"{RECTIFY} 房产/搬迁"), + ("house:9", "sourced", f"{MAPPING} 长途旅行 9宫"), + ("house:12", "sourced", f"{MAPPING} 外国事务 12宫"), + ], + "family": [ + ("D4", "blocked", f"{RECTIFY} 把 D4 标给房产/搬迁,未标给家庭领域"), + ("D7", "blocked", f"{ROUTER} §2 把 D7 标给子女,未标给家庭领域"), + ("D12", "sourced", f"{ROUTER} §2 D12 for family/ancestral themes"), + ("natural", "blocked", f"{MAPPING} 未给出家庭领域的一组自然代表星"), + ], + "annual": [ + ("tajika", "sourced", VARSHAPHALA), + ("Muntha", "sourced", f"{VARSHAPHALA} Muntha"), + ("year_lord", "sourced", f"{VARSHAPHALA} 年主星"), + ("Mudda", "sourced", f"{VARSHAPHALA} Mudda Dasha"), + ("Sahams", "sourced", VARSHAPHALA), + ("natural", "blocked", "references/ 未给出流年单独的一组自然代表星"), + ("house", "blocked", "references/ 未把流年钉在单一本命宫"), + ], + "timing": [ + ("narayana_sub", "sourced", NARAYANA), + ("year_chart", "sourced", VARSHAPHALA), + ("natural", "blocked", "references/ 未给出时间领域单独的一组自然代表星"), + ("house", "blocked", "references/ 未把时间领域钉在单一本命宫"), + ], + "general": [ + ("D1", "sourced", f"{ROUTER} §2 D1 Rashi"), + ("D9", "sourced", f"{ROUTER} §2 D9 Navamsa"), + ("D10", "blocked", f"{ROUTER} §2 把 D10 标给事业,未标给综合领域"), + ("D2", "blocked", f"{ROUTER} §2 把 D2 标给财富,未标给综合领域"), + ("AL", "sourced", f"{PDF} Arudha Lagna"), + ("Karakamsha", "sourced", f"{JAIMINI} Karakamsha"), + ("natural", "blocked", "references/ 未给出综合领域的一组自然代表星"), + ("house:1", "sourced", f"{MAPPING} 第1宫"), + ], + } + catalog = {} + for domain, rows in extra.items(): + catalog[domain] = [ + {"id": item_id, "status": status, "citation": citation} + for item_id, status, citation in ([*shared, *rows]) + ] + return catalog + + +def _shared_participation(view: dict, minute_ok: bool) -> list[dict[str, Any]]: + items = [ + _sourced("sudarshana", f"{ROUTER} Sudarshana Chakra", _leaf(view, "worksheets.divisional_and_special_charts.sudarshana")), + _sourced("d9", f"{ROUTER} §2 D9 Navamsa", _leaf(view, "worksheets.divisional_and_special_charts.varga_full.D9_Navamsa")), + _sourced("bhava_chalit", f"{ROUTER} Full Bhava Chalit", _first(view, [ + "worksheets.d1_rasi_bhava.bhava_chalit", + "worksheets.divisional_and_special_charts.bhava_chalit", + ])), + _sourced("shadbala", f"{ROUTER} §2 Shadbala", _leaf(view, "worksheets.strengths_and_scores.shadbala")), + _sourced("bhava_bala", f"{ROUTER} §2 Shadbala", _leaf(view, "worksheets.strengths_and_scores.bhava_bala")), + _sourced("avastha", "references/deep-varga-avastha-execution-guide.md", _leaf(view, "worksheets.advanced_systems.avasthas")), + _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 星照单独列为参与层的段落"), + _sourced("jaimini_aspects", f"{JAIMINI} Jaimini Drishti", _first(view, [ + "worksheets.advanced_systems.jaimini.rashi_aspects", + "worksheets.advanced_systems.jaimini.drishti", + ])), + _sourced("argala", "references/argala-complete-guide.md", _leaf(view, "worksheets.advanced_systems.argala")), + _sourced("dispositor_chain", f"{ROUTER} §2 dispositor chain", _leaf(view, "worksheets.advanced_systems.dispositor_chains")), + _sourced("vimshottari", VIM, _leaf(view, "worksheets.timing_and_predictive_systems.dasha.current_dasha.lord")), + _sourced("narayana", NARAYANA, _leaf(view, "worksheets.timing_and_predictive_systems.narayana_dasha.current_dasha.md.sign")), + _sourced("yogini", "references/alternative-dasha-systems.md", _leaf(view, "worksheets.timing_and_predictive_systems.yogini_dasha.current.planet")), + _sourced("transits", f"{ROUTER} §2 Transit", _leaf(view, "worksheets.timing_and_predictive_systems.transit_multi_reference")), + _sourced("double_transit", f"{CAREER} §2 Transit / Double Transit", _leaf(view, "worksheets.timing_and_predictive_systems.double_transit_pac")), + _sourced("yogas_hits", "references/event_judgment_skeleton.md", _first(view, [ + "worksheets.advanced_systems.yoga.hits", + "worksheets.advanced_systems.yogas.hits", + ])), + {"id": "kp", "status": "value", "value": "不参与", "citation": "决策 8:KP 不参与", "check": "label"}, + ] + del minute_ok + return items + + +def _varga_item(view: dict, item_id: str, citation: str, code: str, minute_ok: bool) -> dict[str, Any]: + if code in PRECISION_VARGA and not minute_ok: + return {"id": item_id, "status": "withheld", "value": PRECISION_SENTENCE, "citation": citation, "check": "withheld"} + _chart, path = _varga_chart(view, code) + if not path: + return {"id": item_id, "status": "gap", "path": f"varga.{code}", "citation": citation, "check": "path"} + return _sourced(item_id, citation, _leaf(view, path)) + + +def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict[str, Any]]: + lagna = "worksheets.divisional_and_special_charts.special_lagnas" + annual = "worksheets.timing_and_predictive_systems" + if domain == "career": + return [ + _varga_item(view, "D10", f"{ROUTER} §2 D10 for career", "D10", minute_ok), + _sourced("A10", f"{CAREER} §2 A10 / Karma Pada", _leaf(view, f"{lagna}.A10_Karma_Pada.sign")), + _sourced("AL", f"{PDF} Arudha Lagna", _leaf(view, f"{lagna}.Arudha_Lagna.sign")), + _sourced("AmK", f"{JAIMINI} Amatyakaraka", _karaka(view, "7", "Amatyakaraka")), + _sourced("Karakamsha", f"{CAREER} §2 Karakamsha", _leaf(view, "worksheets.advanced_systems.jaimini.karakamsha")), + _sourced("Ghati", f"{DEEP} Ghati Lagna 权力指标", _point(view, [f"{lagna}.Ghati_Lagna.sign"], precision=True, minute_ok=minute_ok)), + _blocked("Vighati", "references/ 未找到 Vighati 条目"), + _sourced("D1_D9_D10", f"{ROUTER} §2 D10 for career", _leaf(view, "worksheets.advanced_systems.inter_chart_linkage")), + ] + if domain == "wealth": + return [ + _varga_item(view, "D2", f"{WEALTH} §3 D2", "D2", minute_ok), + _varga_item(view, "D11", f"{WEALTH} §3 D11", "D11", minute_ok), + _sourced("Hora", f"{DEEP} Hora Lagna 财富指标", _leaf(view, f"{lagna}.Hora_Lagna.sign")), + _blocked("Indu", "references/ 未找到 Indu Lagna 财富条目"), + _sourced("Sree", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna", _first(view, [ + f"{lagna}.Sree_Lagna.sign", + f"{lagna}.Sri_Lagna.sign", + ])), + _blocked("AL2", f"{WEALTH} §3 未列出从 AL 起第 2 宫"), + _blocked("AL11", f"{WEALTH} §3 未列出从 AL 起第 11 宫"), + ] + if domain == "marriage": + return [ + _varga_item(view, "D9_7", f"{MARRIAGE} §2 D9 7H / 7L", "D9", minute_ok), + _sourced("UL", f"{MARRIAGE} §2 UL", _leaf(view, f"{lagna}.Upapada_Lagna.sign")), + _blocked("UL2", f"{MARRIAGE} §2 未列出 UL 起第 2 宫"), + _blocked("A7", f"{MARRIAGE} §2 未列出 A7"), + _sourced("DK", f"{JAIMINI} Darakaraka", _karaka(view, "7", "Darakaraka")), + _sourced("Vivah", f"{MARRIAGE} §2 Vivah Saham", _first(view, [ + f"{annual}.annual_tajika_pack.sahams.data.vivah_saham", + "worksheets.advanced_systems.sahams.vivah_saham", + ])), + _sourced("Mars", f"{MARRIAGE} §2 Mars", _leaf(view, "core_chart.planets.Mars.sign")), + ] + if domain == "health": + return [ + _varga_item(view, "D6", f"{ROUTER} §7 D6", "D6", minute_ok), + _varga_item(view, "D8", f"{ROUTER} §7 D8", "D8", minute_ok), + _varga_item(view, "D30", f"{ROUTER} §7 D30", "D30", minute_ok), + _sourced("drekkana22", f"{SHODASA} 第22Drekkana与寿限", _first(view, [ + "worksheets.divisional_and_special_charts.sensitive_points.drekkana_22", + "worksheets.divisional_and_special_charts.varga_full.D3_Drekkana", + ])), + _blocked("navamsa64", "references/ 未找到第 64 个 Navamsa 条目"), + _sourced("Badhaka", BADHAKA, _first(view, [ + "worksheets.advanced_systems.badhaka", + "worksheets.d1_rasi_bhava.badhaka", + ])), + _blocked("Gulika", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"), + _blocked("Mandi", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"), + _blocked("Pranapada", "references/prashna-complete-guide.md 的 Pranapada 是问事替代计算,未列入本命健康参与层"), + ] + if domain == "children": + return [ + _varga_item(view, "D7", f"{ROUTER} §2 D7 for children", "D7", minute_ok), + _sourced("PK", f"{JAIMINI} Putrakaraka", _karaka(view, "7", "Putrakaraka")), + ] + if domain == "parents": + return [ + _varga_item(view, "D12", f"{ROUTER} §2 D12 for family/ancestral themes", "D12", minute_ok), + _sourced("MK", f"{JAIMINI} Matrikaraka", _karaka(view, "7", "Matrikaraka")), + _sourced("PiK", f"{JAIMINI} 可选PiK", _karaka(view, "8", "Pitrukaraka")), + ] + if domain == "education": + return [ + _varga_item(view, "D24", f"{ROUTER} §2 D24 for education", "D24", minute_ok), + _blocked("D5", "references/ 未找到把 D5 标成学业分盘的段落"), + ] + if domain == "migration": + return [ + _varga_item(view, "D4", f"{RECTIFY} 房产/搬迁 D4", "D4", minute_ok), + _blocked("D12", f"{ROUTER} §2 把 D12 标给家族,未标给迁居"), + ] + if domain == "family": + return [ + _blocked("D4", f"{RECTIFY} 把 D4 标给房产/搬迁,未标给家庭领域"), + _blocked("D7", f"{ROUTER} §2 把 D7 标给子女,未标给家庭领域"), + _varga_item(view, "D12", f"{ROUTER} §2 D12 for family/ancestral themes", "D12", minute_ok), + ] + if domain == "annual": + return [ + _sourced("tajika", VARSHAPHALA, _leaf(view, f"{annual}.tajika_yogas")), + _sourced("Muntha", f"{VARSHAPHALA} Muntha", _first(view, [ + f"{annual}.annual_tajika_pack.muntha", + f"{annual}.tajika.muntha", + ])), + _sourced("year_lord", f"{VARSHAPHALA} 年主星", _first(view, [ + f"{annual}.annual_tajika_pack.year_lord", + f"{annual}.tajika.year_lord", + ])), + _sourced("Mudda", f"{VARSHAPHALA} Mudda Dasha", _first(view, [ + f"{annual}.annual_tajika_pack.mudda_dasha", + f"{annual}.tajika.mudda_dasha", + ])), + _sourced("Sahams", VARSHAPHALA, _first(view, [ + f"{annual}.annual_tajika_pack.sahams", + "worksheets.advanced_systems.sahams", + ])), + ] + if domain == "timing": + return [ + _sourced("narayana_sub", NARAYANA, _leaf(view, f"{annual}.narayana_dasha.current_dasha.ad.sign")), + _sourced("year_chart", VARSHAPHALA, _first(view, [ + f"{annual}.annual_tajika_pack", + f"{annual}.tajika", + ])), + ] + if domain == "general": + return [ + _varga_item(view, "D1", f"{ROUTER} §2 D1 Rashi", "D1", minute_ok), + _varga_item(view, "D9", f"{ROUTER} §2 D9 Navamsa", "D9", minute_ok), + _blocked("D10", f"{ROUTER} §2 把 D10 标给事业,未标给综合领域"), + _blocked("D2", f"{ROUTER} §2 把 D2 标给财富,未标给综合领域"), + _sourced("AL", f"{PDF} Arudha Lagna", _leaf(view, f"{lagna}.Arudha_Lagna.sign")), + _sourced("Karakamsha", f"{JAIMINI} Karakamsha", _leaf(view, "worksheets.advanced_systems.jaimini.karakamsha")), + ] + return [] + + +def _factors(domain: str, timing: dict[str, Any], karakas: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + catalog = domain_source_catalog()[domain] + factors: list[dict[str, Any]] = [] + for row in catalog: + item_id = row["id"] + if item_id.startswith("house:"): + factors.append({"id": item_id, "kind": "house", "house": int(item_id.split(":")[1]), "citation": row["citation"]}) + elif item_id.startswith("natural:"): + factors.append({"id": item_id, "kind": "planet", "planet": item_id.split(":")[1], "citation": row["citation"], "role": "natural"}) + elif item_id == "natural": + factors.append({"id": item_id, "kind": "blocked", "citation": row["citation"]}) + elif item_id == "house": + factors.append({"id": item_id, "kind": "blocked", "citation": row["citation"]}) + for item_id, role in (("AmK", "Amatyakaraka"), ("DK", "Darakaraka"), ("PK", "Putrakaraka"), ("MK", "Matrikaraka")): + leaf = karakas.get(role) + if leaf and leaf.get("status") == "value" and isinstance(leaf.get("value"), str): + if any(row["id"] == item_id for row in catalog): + factors.append({"id": f"jaimini:{item_id}", "kind": "planet", "planet": leaf["value"], "citation": f"{JAIMINI} {role}", "role": item_id}) + elif any(row["id"] == item_id for row in catalog): + factors.append({"id": f"jaimini:{item_id}", "kind": "gap", "citation": f"{JAIMINI} {role}", "path": leaf.get("path") if leaf else None}) + pik = karakas.get("Pitrukaraka") + if any(row["id"] == "PiK" for row in catalog): + if pik and pik.get("status") == "value" and isinstance(pik.get("value"), str): + factors.append({"id": "jaimini:PiK", "kind": "planet", "planet": pik["value"], "citation": f"{JAIMINI} 可选PiK", "role": "PiK"}) + else: + factors.append({"id": "jaimini:PiK", "kind": "gap", "citation": f"{JAIMINI} 可选PiK", "path": (pik or {}).get("path")}) + if not any(row["id"].startswith("jaimini") or row["id"] in {"AmK", "DK", "PK", "MK", "PiK"} for row in catalog): + factors.append({"id": "jaimini", "kind": "blocked", "citation": f"{JAIMINI} 未给出该领域的 Jaimini 代表星"}) + for key, label in (("md", "Vimshottari MD"), ("ad", "Vimshottari AD"), ("pd", "Vimshottari PD")): + leaf = timing.get(key) or {} + if leaf.get("status") == "value" and isinstance(leaf.get("value"), str): + factors.append({"id": f"vimshottari:{key}", "kind": "planet", "planet": leaf["value"], "citation": VIM, "role": label}) + else: + factors.append({"id": f"vimshottari:{key}", "kind": "gap", "citation": VIM, "path": leaf.get("path")}) + sign = timing.get("narayana_sign") or {} + lord = timing.get("narayana_lord") or {} + factors.append({"id": "narayana:md", "kind": "sign", "citation": NARAYANA, "sign": sign, "lord": lord}) + if lord.get("status") == "value" and isinstance(lord.get("value"), str): + factors.append({"id": "narayana:lord", "kind": "planet", "planet": lord["value"], "citation": NARAYANA, "role": "Narayana MD lord"}) + else: + factors.append({"id": "narayana:lord", "kind": "gap", "citation": NARAYANA, "path": lord.get("path")}) + return factors + + +def build_domain_facts(packet: dict, domain: str, *, minute_confirmed: bool) -> dict[str, Any]: + if domain not in PACKET_DOMAIN: + raise KeyError(domain) + view = read_full_data_domain(packet, PACKET_DOMAIN[domain]) + varga = { + "career": "D10", "wealth": "D2", "marriage": "D9", "health": "D6", + "children": "D7", "parents": "D12", "education": "D24", "migration": "D4", + "family": "D4", "annual": "D1", "timing": "D9", "general": "D10", + }[domain] + planets = {name: _planet_record(view, name, varga, minute_confirmed) for name in PLANETS} + houses = {} + for row in domain_source_catalog()[domain]: + if row["id"].startswith("house:"): + number = int(row["id"].split(":")[1]) + houses[str(number)] = _house_record(view, number) + timing = _timing(view) + karakas = { + "Amatyakaraka": _karaka(view, "7", "Amatyakaraka"), + "Darakaraka": _karaka(view, "7", "Darakaraka"), + "Putrakaraka": _karaka(view, "7", "Putrakaraka"), + "Matrikaraka": _karaka(view, "7", "Matrikaraka"), + "Pitrukaraka": _karaka(view, "8", "Pitrukaraka"), + } + participation = _shared_participation(view, minute_confirmed) + _domain_participation(view, domain, minute_confirmed) + facts = { + "schema": "consult-card-facts-v1", + "domain": domain, + "packet_domain": PACKET_DOMAIN[domain], + "header_note": RAHU_NOTE, + "precision_sentence": PRECISION_SENTENCE, + "minute_confirmed": minute_confirmed, + "domain_varga": varga, + "planets": planets, + "houses": houses, + "timing": timing, + "factors": _factors(domain, timing, karakas), + "participation": participation, + } + encoded = json.dumps(facts, ensure_ascii=False, default=str) + if "tajika_named_yogas" in encoded: + raise RuntimeError("consult card facts must not read tajika_named_yogas") + return facts + + +def build_consult_card_facts( + packet: dict, + domains: list[str], + *, + minute_confirmed: bool | None, + body: dict | None = None, +) -> dict[str, Any]: + confirmed = minute_confirmed_from_body(body) if minute_confirmed is None else minute_confirmed + rows = [] + for domain in domains: + if domain not in PACKET_DOMAIN: + continue + rows.append(build_domain_facts(packet, domain, minute_confirmed=confirmed)) + return { + "schema": "consult-card-facts-v1", + "minute_confirmed": confirmed, + "domains": rows, + "gaps": [] if rows else ["consult_domain_missing"], + } + + +def assert_fact_leaves(packet: dict, facts: dict[str, Any]) -> None: + """Every copied leaf equals the packet. Derived selections are not leaves.""" + view = read_full_data_domain(packet, facts["packet_domain"]) + + def check(item: Any) -> None: + if isinstance(item, list): + for entry in item: + check(entry) + return + if not isinstance(item, dict): + return + kind = item.get("check") + if kind == "path" and item.get("status") == "value": + assert dig(view, item["path"]) == item["value"], item["path"] + elif kind == "index" and item.get("status") == "value": + assert dig(view, item["path"])[item["index"]] == item["value"], item["path"] + elif kind == "nonempty" and item.get("status") == "value": + assert dig(view, item["path"]) not in (None, {}, [], ""), item["path"] + elif kind == "membership" and item.get("status") == "value": + assert item["member"] in dig(view, item["path"]), item["path"] + elif kind == "withheld": + assert item.get("value") == PRECISION_SENTENCE + for key, value in item.items(): + if key in ("value", "path", "citation"): + continue + check(value) + + check(facts) diff --git a/scripts/consult_full_data_cache.py b/scripts/consult_full_data_cache.py new file mode 100644 index 00000000..644b19be --- /dev/null +++ b/scripts/consult_full_data_cache.py @@ -0,0 +1,182 @@ +"""Cache the full-data report packet for the consult card. + +The key is chart identity + ayanamsa + node mode + engine version. +The file name is a hash. Birth data stays inside the scratch file. +""" + +from __future__ import annotations + +import hashlib +import json +import time +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" +SLOW_MISS_SECONDS = 20.0 + +PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]] + + +def cache_dir() -> Path: + path = ROOT / "scratch" / "local" / "consult_full_data_cache" + path.mkdir(parents=True, exist_ok=True) + return path + + +def engine_version() -> str: + try: + from jyotish_vedic import __version__ as version + except Exception: + return "unknown" + return str(version) + + +def _number(value: Any) -> int | float: + number = float(value) + if number == int(number): + return int(number) + return number + + +def identity_from_body(body: dict[str, Any]) -> 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") + second = body.get("second", 0) + return { + "schema": CACHE_SCHEMA, + "year": int(body["year"]), + "month": int(body["month"]), + "day": int(body["day"]), + "hour": _number(body["hour"]), + "minute": _number(body["minute"]), + "second": _number(second if second is not None else 0), + "lat": _number(body["lat"]), + "lon": _number(body["lon"]), + "tz": _number(body["tz"]), + "ayanamsa": str(ayanamsa), + "node_mode": str(node or "mean"), + "engine_version": engine_version(), + } + + +def cache_key(identity: dict[str, Any]) -> str: + canonical = json.dumps(identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _cache_path(identity: dict[str, Any]) -> Path: + return cache_dir() / f"{cache_key(identity)}.json" + + +def get_or_build( + identity: dict[str, Any], + builder: PacketBuilder | None = None, + *, + clock: Callable[[], float] | None = None, +) -> dict[str, Any]: + """Return the packet. A miss calls builder once and stores the packet.""" + now = clock or time.perf_counter + started = now() + path = _cache_path(identity) + if path.is_file(): + packet = json.loads(path.read_text(encoding="utf-8")) + elapsed = now() - started + return {"packet": packet, "hit": True, "elapsed_s": elapsed, "slow": False} + if builder is None: + builder = build_full_data_packet + packet = builder(identity) + temporary = path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + temporary.replace(path) + elapsed = now() - started + return { + "packet": packet, + "hit": False, + "elapsed_s": elapsed, + "slow": elapsed > SLOW_MISS_SECONDS, + } + + +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() + args = SimpleNamespace( + year=int(identity["year"]), + month=int(identity["month"]), + day=int(identity["day"]), + hour=float(identity["hour"]), + minute=float(identity["minute"]), + second=float(identity["second"]), + lat=float(identity["lat"]), + lon=float(identity["lon"]), + 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(), + 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) + return attach_calculation_profile(packet, args) + + +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") + if raw is None: + raw = body.get("themes", body.get("theme")) + if raw is None: + return [] + if isinstance(raw, str): + return [raw] + if isinstance(raw, list): + return [str(item) for item in raw if isinstance(item, str)] + return [] + + +def attach_consult_full_data(result: dict[str, Any], body: dict[str, Any] | None) -> dict[str, Any]: + """Thin hook for the consultation response. Failures do not break the chat.""" + payload = dict(body or {}) + if payload.get("include_consult_card_facts") is not True: + return result + try: + from consult_card_domain_facts import build_consult_card_facts + except ImportError: + from scripts.consult_card_domain_facts import build_consult_card_facts + try: + identity = identity_from_body(payload) + cached = get_or_build(identity) + facts = build_consult_card_facts( + cached["packet"], + _requested_domains(payload), + minute_confirmed=None, + body=payload, + ) + facts["timing"] = { + "hit": bool(cached["hit"]), + "elapsed_s": cached["elapsed_s"], + "slow": bool(cached["slow"]), + } + except Exception as exc: + facts = { + "schema": "consult-card-facts-v1", + "gaps": ["consult_full_data_unavailable"], + "error_type": type(exc).__name__, + } + attached = dict(result) + attached["consult_card_facts"] = facts + return attached diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 92120774..6ed3eaeb 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4331,11 +4331,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM }) def _compute_consultation_workflow(self, body): - return execute_consultation_workflow( + result = execute_consultation_workflow( self, body=dict(body or {}), surface=body.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 {}) def _build_audited_remedies_from_guided_topics(self, guided_topics): if not isinstance(guided_topics, list): diff --git a/tests/test_consult_card_full_source.py b/tests/test_consult_card_full_source.py new file mode 100644 index 00000000..e6899b15 --- /dev/null +++ b/tests/test_consult_card_full_source.py @@ -0,0 +1,202 @@ +"""Consult-card facts are copied from the full-data packet.""" + +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path + +import pytest + +from consult_card_domain_facts import ( + CONSULTATION_DOMAINS, + PRECISION_SENTENCE, + RAHU_NOTE, + assert_fact_leaves, + build_domain_facts, + domain_source_catalog, + minute_confirmed_from_body, +) +from consult_full_data_cache import ( + attach_consult_full_data, + get_or_build, + identity_from_body, +) + +ROOT = Path(__file__).resolve().parents[1] +DENSITY = ROOT / "frontend" / "tests" / "fixtures" / "report-density-fictional-engine.json" +PUBLIC = ROOT / "references" / "public_oracle_cases.json" +TAYLOR = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json" + + +def _packet() -> dict: + return json.loads(DENSITY.read_text(encoding="utf-8")) + + +def test_every_domain_row_is_sourced_or_blocked(): + catalog = domain_source_catalog() + assert set(catalog) == set(CONSULTATION_DOMAINS) + for domain, rows in catalog.items(): + assert rows, domain + for row in rows: + assert row["status"] in {"sourced", "blocked"}, row["id"] + citation = row["citation"] + assert citation + if row["status"] == "sourced" and "references/" in citation: + relative = citation.split()[0] + assert relative.startswith("references/") + assert (ROOT / relative).is_file(), relative + if row["status"] == "blocked": + assert "references/" in citation or citation.startswith("决策") + + +def test_density_fixture_leaves_match_the_packet(): + packet = _packet() + for domain in CONSULTATION_DOMAINS: + facts = build_domain_facts(packet, domain, minute_confirmed=True) + assert_fact_leaves(packet, facts) + encoded = json.dumps(facts, ensure_ascii=False) + assert "tajika_named_yogas" not in encoded + assert facts["header_note"] == RAHU_NOTE + assert facts["planets"]["Rahu"]["functional_role"]["value"] is None + assert facts["planets"]["Ketu"]["functional_role"]["value"] is None + + +def test_precision_gate_withholds_d16_and_ghati_until_a_minute_is_confirmed(): + packet = _packet() + education = build_domain_facts(packet, "education", minute_confirmed=False) + d24 = next(item for item in education["participation"] if item["id"] == "D24") + assert d24["status"] == "withheld" + assert d24["value"] == PRECISION_SENTENCE + career = build_domain_facts(packet, "career", minute_confirmed=False) + ghati = next(item for item in career["participation"] if item["id"] == "Ghati") + assert ghati["value"] == PRECISION_SENTENCE + confirmed = build_domain_facts(packet, "education", minute_confirmed=True) + d24_confirmed = next(item for item in confirmed["participation"] if item["id"] == "D24") + assert d24_confirmed["status"] != "withheld" + assert minute_confirmed_from_body({"hour": 1, "minute": 1, "declared_accuracy": "rectified"}) is True + assert minute_confirmed_from_body({"hour": 1, "minute": 1, "birth_time_status": "confirmed"}) is True + assert minute_confirmed_from_body({"hour": 1, "minute": 1, "birth_time_status": "accepted"}) is True + assert minute_confirmed_from_body({"hour": 1, "minute": 1, "declared_accuracy": "minute"}) is False + assert minute_confirmed_from_body({"hour": 1, "minute": 1, "birth_time_accuracy": "provisional"}) is False + assert minute_confirmed_from_body({"hour": 1, "minute": 1}) is False + assert minute_confirmed_from_body({"declared_accuracy": "rectified"}) is False + + +def test_cache_miss_then_hit_and_birth_change_misses(): + token = uuid.uuid4().hex + calls = {"n": 0} + + def builder(identity): + calls["n"] += 1 + return {"marker": identity["engine_version"], "minute": identity["minute"]} + + base = { + "schema": "consult-full-data-cache-v1", + "year": 1901, + "month": 1, + "day": 1, + "hour": 0, + "minute": 1, + "second": 0, + "lat": 0, + "lon": 0, + "tz": 0, + "ayanamsa": "raman", + "node_mode": "mean", + "engine_version": token, + } + first = get_or_build(base, builder) + second = get_or_build(base, builder) + changed = dict(base) + changed["minute"] = 2 + third = get_or_build(changed, builder) + assert first["hit"] is False + assert second["hit"] is True + assert third["hit"] is False + assert calls["n"] == 2 + assert second["elapsed_s"] < 1.66 + assert second["packet"]["minute"] == 1 + assert third["packet"]["minute"] == 2 + + +def test_attach_skips_unless_asked_and_gaps_without_a_birth_dump(): + original = {"success": True} + assert attach_consult_full_data(original, {"theme": "career"}) is original + failed = attach_consult_full_data({"success": True}, {"include_consult_card_facts": True}) + facts = failed["consult_card_facts"] + assert facts["gaps"] == ["consult_full_data_unavailable"] + assert facts["error_type"] + encoded = json.dumps(facts, ensure_ascii=False) + assert "Traceback" not in encoded + assert "year" not in facts + + +def _public_body(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 { + "year": birth["year"], + "month": birth["month"], + "day": birth["day"], + "hour": birth["hour"], + "minute": birth["minute"], + "second": birth.get("second", 0), + "lat": birth["lat"], + "lon": birth["lon"], + "tz": birth["tz"], + "ayanamsa": "raman", + "node_mode": "mean", + } + + +def _taylor_body() -> 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 { + "year": year, + "month": month, + "day": day, + "hour": int(clock[0]), + "minute": int(clock[1]), + "second": int(clock[2]) if len(clock) > 2 else 0, + "lat": birth["latitude"], + "lon": birth["longitude"], + "tz": birth["timezone_offset"], + "ayanamsa": "raman", + "node_mode": "mean", + } + + +@pytest.mark.parametrize("label,body", [ + ("jobs", None), + ("obama", None), + ("taylor", None), +]) +def test_public_charts_copy_every_domain(label, body): + del body + selected = { + "jobs": _public_body("steve_jobs_1955_aa"), + "obama": _public_body("barack_obama_1961_aa"), + "taylor": _taylor_body(), + }[label] + cached = get_or_build(identity_from_body(selected)) + if cached["slow"]: + pytest.fail(f"{label} miss {cached['elapsed_s']:.3f}s exceeds 20s") + packet = cached["packet"] + slowest = 0.0 + for domain in CONSULTATION_DOMAINS: + started = time.perf_counter() + facts = build_domain_facts(packet, domain, minute_confirmed=True) + elapsed = time.perf_counter() - started + slowest = max(slowest, elapsed) + 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"]) + 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}")