Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9324253408 | ||
|
|
0d73cd332b | ||
|
|
71f7fac3a1 | ||
|
|
454951de27 | ||
|
|
ab7f66be14 |
@@ -1,5 +1,10 @@
|
|||||||
# BLOCKED
|
# BLOCKED
|
||||||
|
|
||||||
|
## TASK-answer-effort-ab-20261013:答题步推理强度的真实 A/B(2026-10-15)
|
||||||
|
|
||||||
|
- **A/B 未跑:缺模型 key。** 本机没有 `DS_KEY` 或 `BACKTEST_MODEL_KEY`。供应商是否接受 `low` / `medium` / `high` / `max`、推理 token 和耗时有没有下降,都没测。不得把请求体拦截写成「A/B 通过」。
|
||||||
|
- 开关默认不设,请求体与改前一致。默认值等有 key 的验收跑完再由产品拍板,本单不改部署。
|
||||||
|
|
||||||
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
|
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
|
||||||
|
|
||||||
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
|
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
## 2026-10-15 — 合盘更快(待合入 staging)
|
## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging)
|
||||||
|
|
||||||
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
|
- 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。
|
||||||
|
|
||||||
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
|
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
|
||||||
|
|
||||||
|
|||||||
+57
-68
@@ -15304,17 +15304,17 @@
|
|||||||
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
||||||
|
|
||||||
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
||||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-14
|
- 首次发现 / 最近更新:2026-10-01 / 2026-10-15
|
||||||
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
||||||
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
||||||
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
||||||
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
||||||
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
||||||
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
||||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。
|
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。2026-10-15 issue #65 的请求体探针也是同一形状:`answer-effort-probe.mts` 放在 `frontend/scripts/research/`,用 `new URL` 去读 `frontend/tests` 的夹具。探针挪到 `frontend/tests/answer-effort-probe.mts`,文件名不是 `.test.ts`。业务代码没有改。
|
||||||
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
||||||
- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。
|
- 复发自:无。2026-10-14 与 2026-10-15 都是同一形状,被守卫拦住,未进测试环境。
|
||||||
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。
|
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。2026-10-15 的同类挪动在 `codex/answer-effort-ab-20261013`。
|
||||||
|
|
||||||
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||||
|
|
||||||
@@ -16174,6 +16174,7 @@
|
|||||||
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
||||||
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
||||||
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
||||||
|
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
|
||||||
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
||||||
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
||||||
- 复发自:无
|
- 复发自:无
|
||||||
@@ -17990,36 +17991,6 @@
|
|||||||
- 复发自:无。
|
- 复发自:无。
|
||||||
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
||||||
|
|
||||||
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
|
|
||||||
|
|
||||||
- 状态:fixed-pending-verify
|
|
||||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
|
||||||
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
|
|
||||||
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
|
|
||||||
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
|
|
||||||
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
|
|
||||||
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
|
|
||||||
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
|
|
||||||
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
|
|
||||||
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
|
|
||||||
- 复发自:无。
|
|
||||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
|
|
||||||
|
|
||||||
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
|
|
||||||
|
|
||||||
- 状态:fixed-pending-verify
|
|
||||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
|
||||||
- 来源:issue #64。任务书 T2。
|
|
||||||
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
|
|
||||||
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
|
|
||||||
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
|
|
||||||
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
|
|
||||||
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
|
||||||
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
|
|
||||||
- 相关记录:BUG-721、BUG-1265、BUG-1333。
|
|
||||||
- 复发自:无。
|
|
||||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
|
|
||||||
|
|
||||||
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
||||||
|
|
||||||
- 状态:investigating
|
- 状态:investigating
|
||||||
@@ -33401,17 +33372,17 @@
|
|||||||
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
||||||
|
|
||||||
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
||||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-14
|
- 首次发现 / 最近更新:2026-10-01 / 2026-10-15
|
||||||
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
||||||
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
||||||
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
||||||
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
||||||
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
||||||
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
||||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。
|
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。2026-10-15 issue #65 的请求体探针也是同一形状:`answer-effort-probe.mts` 放在 `frontend/scripts/research/`,用 `new URL` 去读 `frontend/tests` 的夹具。探针挪到 `frontend/tests/answer-effort-probe.mts`,文件名不是 `.test.ts`。业务代码没有改。
|
||||||
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
||||||
- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。
|
- 复发自:无。2026-10-14 与 2026-10-15 都是同一形状,被守卫拦住,未进测试环境。
|
||||||
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。
|
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。2026-10-15 的同类挪动在 `codex/answer-effort-ab-20261013`。
|
||||||
|
|
||||||
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||||
|
|
||||||
@@ -34271,6 +34242,7 @@
|
|||||||
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
||||||
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
||||||
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
||||||
|
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
|
||||||
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
||||||
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
||||||
- 复发自:无
|
- 复发自:无
|
||||||
@@ -36072,36 +36044,6 @@
|
|||||||
- 复发自:无。
|
- 复发自:无。
|
||||||
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
||||||
|
|
||||||
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
|
|
||||||
|
|
||||||
- 状态:fixed-pending-verify
|
|
||||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
|
||||||
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
|
|
||||||
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
|
|
||||||
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
|
|
||||||
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
|
|
||||||
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
|
|
||||||
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
|
|
||||||
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
|
|
||||||
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
|
|
||||||
- 复发自:无。
|
|
||||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
|
|
||||||
|
|
||||||
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
|
|
||||||
|
|
||||||
- 状态:fixed-pending-verify
|
|
||||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
|
||||||
- 来源:issue #64。任务书 T2。
|
|
||||||
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
|
|
||||||
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
|
|
||||||
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
|
|
||||||
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
|
|
||||||
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
|
||||||
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
|
|
||||||
- 相关记录:BUG-721、BUG-1265、BUG-1333。
|
|
||||||
- 复发自:无。
|
|
||||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
|
|
||||||
|
|
||||||
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
||||||
|
|
||||||
- 状态:investigating
|
- 状态:investigating
|
||||||
@@ -36208,3 +36150,50 @@
|
|||||||
- 复发自:无
|
- 复发自:无
|
||||||
- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。
|
- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。
|
||||||
|
|
||||||
|
## BUG-1352 | 星盘页上升行的星宿三项被排盘接口丢掉
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-10-03
|
||||||
|
- 最近更新:2026-10-15
|
||||||
|
- 影响面:星盘页本命盘和月亮盘的上升行(星宿、宿主、pada)
|
||||||
|
- 用户现象:这两行的星宿、宿主、pada、状态都是「—」,像没算出来。九曜那几行是有的。
|
||||||
|
- 触发条件:打开星盘页的本命盘或月亮盘。上升经度其实已经算好。
|
||||||
|
- 根因:引擎给上升写了星宿三项,排盘接口在重组结果时只留了星座和度数,把这三项丢了。月亮盘用的是同一份上升。上次修复只测了引擎,前端样例又是事后补上这三项的,所以接口这一层一直没被发现。
|
||||||
|
- 修复:接口重组上升时原样带上这三项;引擎没给时按同一套星宿算法补上。排盘缓存版本 6 改为 7,避免继续用没有这三项的旧结果。
|
||||||
|
- 验证:虚构盘(1990-06-15 12:00 北京)走排盘处理函数,上升和 `modules.chart.ascendant` 的三项与星宿算法一致,为 Uttara Phalguni、1、Sun。本命表和月亮盘表都渲染出这三项。
|
||||||
|
- 防复发:回归测试必须经过排盘处理函数,不能只测引擎,也不能手工往样例里补字段。
|
||||||
|
- 相关记录:BUG-1201、BUG-1314
|
||||||
|
- 复发自:BUG-1201(修复没有打通接口层)
|
||||||
|
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。任务书写明 BUG-1351 已被另一单占用,本单从 BUG-1352 起。
|
||||||
|
|
||||||
|
## BUG-1353 | 星盘表居中列的表头靠左,当前大运卡片左边多一条线
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-10-15
|
||||||
|
- 最近更新:2026-10-15
|
||||||
|
- 影响面:星盘页行星表的表头,以及大运列表里当前这一段
|
||||||
|
- 用户现象:分盘「庙旺」的表头靠左、格子里的字居中,对不齐。当前大运卡片左边有一条竖线。
|
||||||
|
- 触发条件:打开星盘页的分盘表或大运列表。
|
||||||
|
- 根因:居中只加在内容格子上,表头没有。八列表的写法是冻结的,不能改标记。大运当前段用内阴影画了左侧强调线,设计说明里没写,也没有测试盯着。
|
||||||
|
- 修复:表头与内容一起居中。本命和月亮盘的八列表不改标记,用列选择器把速度、宿主、pada 的表头居中,宫位仍靠左。分盘的庙旺和同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。当前大运去掉左侧那条线,浅底和细边框留着。
|
||||||
|
- 验证:样式合同锁定八列表对应表头居中、宫位列仍靠左,以及当前大运规则里没有 box-shadow。分盘、行运、Bhava 的渲染测试锁定表头带居中标记。
|
||||||
|
- 防复发:上述样式合同和渲染测试。
|
||||||
|
- 相关记录:BUG-1076、BUG-1352
|
||||||
|
- 复发自:无
|
||||||
|
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。
|
||||||
|
|
||||||
|
## BUG-1354 | 低强度推理设置写在嵌套字段里,供应商收不到
|
||||||
|
|
||||||
|
- 状态:investigating
|
||||||
|
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||||
|
- 来源:issue #65。任务书 `docs/tasks/TASK-answer-effort-ab-20261013.md`。请求体拦截见 `docs/testing/answer-effort-ab-20261013.md`。
|
||||||
|
- 现象:代码里有一处把推理强度写成 `low`,但真正发出去的请求没有 `reasoning_effort` 这个字段。强度被包在 `thinking` 里面。
|
||||||
|
- 触发条件:调用 `consultationSliceGenerationSettings`。产品代码里没有调用方,线上对话不受影响。
|
||||||
|
- 根因:兼容通道只认供应商选项最外层的 `reasoningEffort`,并把它写成 `reasoning_effort`。嵌在 `thinking` 里的同名键会原样送出去,供应商不把它当强度。
|
||||||
|
- 修复:本单不改这处形状,也不改它的两条断言。答题步的新开关写在最外层。要不要删掉或改掉这处旧形状,等产品拍板。
|
||||||
|
- 验证:`frontend/tests/answer-effort-probe.mts --dry-run`。这处设置的请求体是 `thinking.reasoningEffort = "low"`,`reasoning_effort` 为空。新开关设成 `low` 时,答题步请求体有 `reasoning_effort: "low"`。
|
||||||
|
- 防复发:新测试锁住「开关不设时请求体与改前一致」,并锁住新强度出现在最外层。
|
||||||
|
- 相关记录:BUG-148、BUG-1142。
|
||||||
|
- 复发自:无。
|
||||||
|
- 修复版本:未修。分支 `codex/answer-effort-ab-20261013` 只记录。原先写在 BUG-1351,该号已被数据层占用;按本文件规则保留较早的 BUG-1351,本条改为 BUG-1354,接在 BUG-1353 之后。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# PROGRESS · 答题步推理强度开关(2026-10-15)
|
||||||
|
|
||||||
|
- 分支:`codex/answer-effort-ab-20261013`
|
||||||
|
- 实际基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`
|
||||||
|
- 前置 `consult-prep-parallel` 已在 `origin/staging` 上(`git merge-base --is-ancestor` 退出码 0)
|
||||||
|
- 默认行为不变。没有模型钥匙,A/B 未跑。
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
1. 新环境变量 `CONSULT_ANSWER_REASONING_EFFORT`。`low` / `medium` / `high` / `max` 才生效。空着或没设,请求体与改前一致。非法值 `console.warn` 一次,然后当作没设。
|
||||||
|
2. `agentGenerationSettings` 增加 `providerReasoningEffort`。它写在供应商选项的最外层,兼容通道据此发出 `reasoning_effort`。原来的 `reasoningEffort` 仍嵌在 `thinking` 里,切片那两条断言不用改。
|
||||||
|
3. 答题步才带这个字段。判据与答案时钟相同:计算结果已经在手(`answerReady`),或者公开日历从第一步就是答题(`answerFromStart`)。主循环第 0 步计算结果还没到,不带。重试是新的一轮,步号从 0 重新计,但计算结果已经在手,所以重试的第 0 步也带。续写仍是关闭思考,不带这个字段。
|
||||||
|
4. Mastra 的 `prepareStep` 会按步合并 `providerOptions`(`mergeObjects`)。答题步返回的强度会并进这一步,不会整轮都带。本单按步生效,没有改成整轮。
|
||||||
|
5. 生平回测脚本增加 `--cases figure:domain,…` 和 `--efforts unset,low,medium`。每行记下端到端秒数、第 0 / 1 步秒数和推理 token、输出 token、结束原因、是否空答、是否截断、正文字数。摘要记下当次强度。
|
||||||
|
|
||||||
|
## 自测
|
||||||
|
|
||||||
|
| 检查 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 请求体拦截 | `node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run` | 退出码 0。数字见 `docs/testing/answer-effort-ab-20261013.md`。2026-10-15 打回后脚本从 `frontend/scripts/research/` 挪到 `frontend/tests/`,命令改成这一条。 |
|
||||||
|
| 本单测试与任务书点名的四份 | `node node_modules/tsx/dist/cli.mjs --test` 后接 `answer-effort-switch-20261013`、`consultation-agentic-runtime`、`consultation-workflow-contract`、`consult-answer-clock-20261001`、`consult-latency-timing-20261005` | 114 pass,0 fail,0 cancelled |
|
||||||
|
| 类型检查 | 在 `frontend` 下 `.\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||||
|
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning。探针里多出来的 1 条未使用变量已去掉,改过的文件再跑 eslint 是 0 |
|
||||||
|
|
||||||
|
开关不设时,两步请求体与改前保存的整份 JSON 逐字节一致。设 `low` 时只有答题步出现 `reasoning_effort: "low"`,第 0 步没有;续写仍是 `thinking: disabled` 且没有 `reasoning_effort`。
|
||||||
|
|
||||||
|
## 改既有断言
|
||||||
|
|
||||||
|
| 原值 | 新值 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `await streamWithOverflowRetry(agent)` | `await streamWithOverflowRetry(agent, generalStreamOptions)` | 公开日历从第一步就是答题。不传选项就没有 `prepareStep`,强度加不上去。开关不设时这些选项不改请求体。答案时钟的断言还在。 |
|
||||||
|
|
||||||
|
切片的嵌套形状和它的两条断言没有改。
|
||||||
|
|
||||||
|
## 需拍板
|
||||||
|
|
||||||
|
- 默认强度。本单不改。有钥匙的验收按任务书 T3 出数之后,再决定设不设、设哪一档。部署默认写在哪里也留到那次。
|
||||||
|
- `consultationSliceGenerationSettings` 删掉还是改成最外层。它没有调用方。本单不改。见 BUG-1354(原先写成 BUG-1351,该号已被数据层占用)。
|
||||||
|
- 若供应商不接受降档,对照组 D(答题步关思考)只供参考,采用与否另说。本机没跑。
|
||||||
|
|
||||||
|
## 环境缺口
|
||||||
|
|
||||||
|
A/B 未跑:缺模型 key。没有 `DS_KEY`,也没有 `BACKTEST_MODEL_KEY`。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# PROGRESS · 答题推理强度开关修复(2026-10-15)
|
||||||
|
|
||||||
|
- 分支:`codex/answer-effort-ab-20261013`
|
||||||
|
- 被打回的提交:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`
|
||||||
|
- 修复单:`docs/tasks/TASK-answer-effort-ab-fix-20261015.md`
|
||||||
|
- 已合并 `origin/staging` `71f7fac3a13f817f91755ff01b25b8706380ae19`,合并提交 `0d73cd332bf4efae2b8ec9389d5853384f4616f4`。没有改写已推送的历史。
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
1. 把 `frontend/scripts/research/answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`。文件名不是 `.test.ts`,也不是 `.test.tsx`,全量测试不会把它收成一条用例。
|
||||||
|
2. 相对路径改过:源码从 `../src/...` 引入,夹具改成 `./fixtures/answer-effort-switch-20261013.json`。本单测试改为从同目录引入。已经保存的夹具文件没有重写。
|
||||||
|
3. 命令同步进 `docs/tasks/PROGRESS-answer-effort-ab-20261013.md` 和 `docs/testing/answer-effort-ab-20261013.md`。
|
||||||
|
4. 业务代码没改。开关默认值没改。任务书里留到拍板的默认强度,以及没人调用的旧嵌套设置,都没做。
|
||||||
|
5. 合并时,本分支原来的调查记录占用了 BUG-1351,和数据层的 BUG-1351 撞号。保留 staging 上的 BUG-1351、BUG-1352、BUG-1353。这条调查记录改为 BUG-1354。BUG-1140 补记了这次同一形状的挪动。
|
||||||
|
|
||||||
|
## 自测
|
||||||
|
|
||||||
|
| 检查 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 镜像守卫和本单测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test tests/runtime-sources-no-test-imports.test.ts tests/answer-effort-switch-20261013.test.ts` | 5 pass,0 fail,0 cancelled |
|
||||||
|
| 请求体拦截 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run` | 退出码 0 |
|
||||||
|
| 类型检查 | 在 `frontend` 下 `.\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||||
|
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 warning |
|
||||||
|
| 隐私标记与 bug 记录格式 | 在工作树根 `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py tests/test_bug_history_workflow.py -q` | 64 pass,0 fail,退出码 0 |
|
||||||
|
|
||||||
|
守卫的两条都在上面 5 条里面:镜像目录不再引用测试目录,并且仍认得当初弄坏构建的那几种写法。
|
||||||
|
|
||||||
|
## prepareStep 会不会盖掉原有选项
|
||||||
|
|
||||||
|
不会整份换掉。开关打开时,答题步返回的供应商选项来自 `agentGenerationSettings`,Mastra 按步做深合并,并进这一步已有的选项。思考仍是打开。输出长度上限在基础流的模型设置里,这一步没有返回它,所以上限还在。缓存标记写在消息上,不在这份流选项里。因此没有往 `BLOCKED.md` 新写一条「被覆盖」。
|
||||||
|
|
||||||
|
## 偏离
|
||||||
|
|
||||||
|
- 没有跑前端全量 `npm test`。这一侧的约定是不跑全量,留给验收。本机只跑了打回指出的那条守卫,以及本单测试。
|
||||||
|
- 修复单写的 `git diff ab7f66be..<新提交> -- frontend/src` 不是空的。原因是按修复单先合并了最新 staging,staging 自己改过星盘页和数据层。本轮修复相对合并提交 `0d73cd33`,`frontend/src` 没有差异。业务代码和开关默认值没动。
|
||||||
|
- A/B 未跑:缺模型 key。没有把请求体拦截写成对照已通过。
|
||||||
|
|
||||||
|
## 环境缺口
|
||||||
|
|
||||||
|
A/B 未跑:缺模型 key。`BLOCKED.md` 里原有的这一条保留,没有新增。
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# PROGRESS · 合盘排盘与报告多余子进程(2026-10-15)
|
|
||||||
|
|
||||||
- 分支:`codex/api-chart-subprocess-gate-20261013`
|
|
||||||
- 实际基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`(任务书写的 `77057a819` 更早)
|
|
||||||
- `scripts/jyotish_api_server.py`:开工 10944 行,做完 10940 行
|
|
||||||
- BUG-1333、BUG-1334。`docs/BUG_HISTORY.md` 后半是同一份历史的副本,两处都补上了。
|
|
||||||
|
|
||||||
## 做了什么
|
|
||||||
|
|
||||||
1. 合盘两次排盘都带 `skip_vedastro_main_entry_overview: true`,不改 `synastryBirthPayload`。两张盘和婚恋分支的两张九分盘都改成同时发出。任一失败仍走原来的 `catch`。
|
|
||||||
2. 前端 `app/api` 和 `lib` 里发往 `/api/chart` 的请求体必须带这个开关。白名单是空的。
|
|
||||||
3. 父进程先看一次能不能导入 PyJHora。不能就不起年度回放和 Tajika 的子进程,返回和「子进程在没装时」相同的结果。样本在 `tests/fixtures/api-chart-subprocess-gate/`,来自本机真实子进程(当时 `find_spec("jhora")` 为 `None`)。装了之后仍走子进程。
|
|
||||||
4. 解释器查找(含「没找到」)和静态 oracle 校验在进程内各记一次。校验按文件路径、mtime、大小作键。不缓存出生资料。
|
|
||||||
5. 开关 `JYOTISH_GATE_CHART_OVERVIEW` 默认关。只有把它设为 `1` 时,不带 skip、也不是异步排队的 `/api/chart` 才占一个重计算名额。`/api/chart` 没有加进 `HEAVY_COMPUTE_PATHS`。预热咨询不占名额的判断挪进了闸门模块。
|
|
||||||
6. 名额占满时打一行日志:被拒路径、当前占用者路径、已占秒数。不写请求正文。
|
|
||||||
|
|
||||||
## 自测
|
|
||||||
|
|
||||||
| 检查 | 命令 | 结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 闸门、增长护栏、本单新测试 | `.venv\Scripts\python.exe -m pytest tests/test_api_chart_subprocess_gate.py tests/test_api_heavy_compute_gate.py tests/test_api_server_growth_contract.py -q` | 21 passed,退出码 0 |
|
|
||||||
| 报告三份既有测试 | 同上解释器 `-m pytest tests/test_report_reader_main.py tests/test_report_longform_parity.py tests/test_report_density_facts.py -q` | 27 passed,退出码 0 |
|
|
||||||
| 前端本单与相关单文件 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test tests/synastry-chart-skip.test.ts tests/chart-request-skip-contract.test.ts tests/chart-view-route.test.ts tests/ephemeris-route.test.ts` | 22 pass,0 fail |
|
|
||||||
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
|
|
||||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning |
|
|
||||||
| A/B | `.venv\Scripts\python.exe scripts\research\api_chart_subprocess_ab.py` | 退出码 0。数字见下 |
|
|
||||||
|
|
||||||
`grep` 这三个名字在 `tests/` 里只命中本单新测试:`annual_pyjhora_replay`、`_oracle_progress_snapshot`、`_resolve_external_pyjhora_python`。
|
|
||||||
|
|
||||||
## A/B 数字
|
|
||||||
|
|
||||||
合盘:串行不带 skip 中位 8.0596 秒、2 次 `subprocess.run`;并发带 skip 中位 0.0166 秒、0 次。盘面摘要逐字节一致。耗时约为对照的 0.2%。
|
|
||||||
|
|
||||||
报告:强制仍起子进程中位 16.5322 秒、7 次;短路后 15.3235 秒、1 次。去掉易变字段后逐字节一致。少掉的 6 次是年度回放和 Tajika。
|
|
||||||
|
|
||||||
名额模拟(种子 64,一分钟,未改规则):
|
|
||||||
|
|
||||||
| 方案 | 咨询被拒 | 报告被拒 | 校正被拒 | 最高并发 | 碰到不得加池的那条 |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| O1 现状 | 40% | 0% | 17% | 2 | 否 |
|
|
||||||
| O2 咨询单独小池 | 0% | 0% | 17% | 3 | 是 |
|
|
||||||
| O3 咨询最多再等 1.5 秒 | 30% | 0% | 33% | 2 | 否 |
|
|
||||||
| O4 重任务最多占 1 个 | 0% | 0% | 67% | 2 | 否 |
|
|
||||||
|
|
||||||
全文在 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
|
||||||
|
|
||||||
## 改既有断言
|
|
||||||
|
|
||||||
无。原值 / 新值 / 原因:没有改旧断言。
|
|
||||||
|
|
||||||
## 偏离
|
|
||||||
|
|
||||||
- `/api/health` 没有加 `heavy_compute` 字段。状态函数 `heavy_compute_status()` 在闸门模块里,接到健康检查会让主文件变长。任务书允许砍掉这一项。
|
|
||||||
- 星盘名额开关默认关。打开会让不带 skip 的外部调用可能拿到 429,任务书要求先有 A/B 再提议,本单不打开。
|
|
||||||
- O2、O3、O4 没有实现。
|
|
||||||
- 报告对照不是 staging 源码另导入,而是本分支把「已安装」强制打开。静态校验缓存两边都在,所以耗时差只反映那 6 个子进程。
|
|
||||||
- 合盘子进程是 2 → 0,不是任务书举例的 4 → 0。这台机器两张盘合计只数到 2 次 `subprocess.run`。
|
|
||||||
- 没有「装了 PyJHora」的计时。父进程强制当成已安装时,子进程仍会因为没装而失败,这一组用来代表「不短路就要付启动开销」。
|
|
||||||
|
|
||||||
## 环境缺口
|
|
||||||
|
|
||||||
- 本机 `.venv`:`sys.executable` 是工作树的 `python.exe`;`shutil.which("python3")` 是 Windows 应用商店占位;`python3.14` 没有;`find_spec("jhora")` 是 `None`。
|
|
||||||
- Docker 在,没有本地 `jyotisha-api` 镜像,镜像里的解释器路径没有核实。
|
|
||||||
- 生产装了 VedAstro、并且开了外网时,不带 skip 的排盘真实耗时本机测不到。
|
|
||||||
- 名额被占满后连带拒绝的真实频率,要等日志上了 staging 再看。
|
|
||||||
|
|
||||||
## 需拍板
|
|
||||||
|
|
||||||
轻重任务要不要分开限流,本单不改。模拟里,让咨询少被拒,校正就会多被拒;给咨询单独开池会让同时在跑的个数超过 2。要改规则需要另立一单。
|
|
||||||
|
|
||||||
## 默认值
|
|
||||||
|
|
||||||
- `JYOTISH_GATE_CHART_OVERVIEW` 默认关。
|
|
||||||
- 合盘 skip 和报告短路没有开关。
|
|
||||||
|
|
||||||
## 2026-10-15 打回修复
|
|
||||||
|
|
||||||
合盘路由的合同跟上了新写法:仍锁本人和对方各请求一次排盘,并锁住关闭外部概览。对照脚本默认写到系统临时目录,只有带 `--out` 才写指定文件,不再覆盖已入库的记录。业务代码没改。名额配额仍不实现。没有新 bug 编号,补在 BUG-1333 的两处副本上。
|
|
||||||
|
|
||||||
| 检查 | 命令 | 结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 用户数据合同 | `.venv\Scripts\python.exe -m pytest tests/test_supabase_user_data_contract.py -q` | 8 passed,退出码 0 |
|
|
||||||
| 脚本默认路径 | 调用 `resolve_record_path` | 默认不在仓库内,也不是已入库的 json;`--out` 指向传入路径 |
|
|
||||||
| 源码文本检索 | `tests` 与 `frontend/tests` 里的合盘路由、`postPython("/api/chart"`、`postPython("/api/varga_full"` | 只有这一条合同在断言旧写法,已改。其它命中是在读路由文件,没有断言旧的两次调用。分盘前缀仍匹配,没改 |
|
|
||||||
| 类型检查 | 在 `frontend` 下 `node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
|
||||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning |
|
|
||||||
|
|
||||||
原值:`postPython("/api/chart", selfPayload)` 与 `postPython("/api/chart", partnerPayload)`。新值:两次调用都展开对应 payload,并带 `skip_vedastro_main_entry_overview: true`。原因:合盘改为同时排两张盘并关掉用不上的外部概览,合同要锁住这两次请求和这个开关,不放宽。
|
|
||||||
|
|
||||||
偏离:先合并了 `origin/staging` `1de3cc307be9134a3b8d1d606c738c1468414118`。相对打回 SHA `336286e4`,`frontend/src` 里多出来的是数据访问层那一单,不是这次修复改的。技能目录下的同名脚本没改,它的输出根不是仓库的 `docs/testing`。
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# PROGRESS · 星盘页上升行(issue #53 第二轮)
|
||||||
|
|
||||||
|
- 分支:`codex/chart-ascendant-row-20261015`
|
||||||
|
- 基线:`origin/staging` `2b7112a6010acbcc0fec809b6b27e2375293dd5c`
|
||||||
|
- 编号:本文件最大号是 BUG-1350。任务书说 BUG-1351 已被 #65 的分支占用,本单用 BUG-1352(上升星宿被接口丢掉,复发自 BUG-1201)和 BUG-1353(表头错位与大运左边线)。
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
- `/api/chart` 重组上升时带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`。引擎已经有就原样带上;没有才按同一套星宿算法补。`cache_schema_version` 6 → 7。
|
||||||
|
- 上升行状态从「—」改为「不适用」。本命、月亮盘共用这一行。
|
||||||
|
- 八列表的标记没改。速度、宿主、pada 的表头用列选择器居中,宫位仍靠左。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。
|
||||||
|
- 当前大运去掉 `box-shadow`,浅底和细边框还在。
|
||||||
|
|
||||||
|
## 样例
|
||||||
|
|
||||||
|
`chart-view-golden.json` 里上升的三项本来就是 Uttara Phalguni、1、Sun,和这次接口真实返回一致,数值没有手改。`sourceCommand` 不再写「用 `_nakshatra_fields` 事后补上」,改为写明来自 `_compute_chart_sync`。
|
||||||
|
|
||||||
|
## 断言改动
|
||||||
|
|
||||||
|
| 位置 | 原值 | 新值 | 原因 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `chart-varga-table.test.tsx` 上升行状态格 | `<td>—</td>` | `<td>不适用</td>` | 产品把横杠看成没数据 |
|
||||||
|
| `test_api_scratch_retention.py` 缓存版本 | `6` | `7` | 旧缓存没有星宿三项,必须换键 |
|
||||||
|
| 基线 html | 仍是改前的整张表 | 不改文件 | 测试用替换得到新表,原值留在基线里 |
|
||||||
|
|
||||||
|
## 自测
|
||||||
|
|
||||||
|
- `pytest tests/test_api_chart_ascendant_nakshatra.py tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py tests/test_api_scratch_retention.py -q`:34 通过,退出码 0。没有新增 `JyotishAPIHandler.__new__`。
|
||||||
|
- 前端:`chart-varga-table`、`chart-moon-transit`、`chart-bhava`、`chart-page-mobile-width-20260929`、`runtime-sources-no-test-imports`。月亮盘那条第一次用整页去配「上升」,配到了图上的标记;改成只看月亮盘表格后通过。
|
||||||
|
- `tsc --noEmit -p .`:0 个错误,退出码 0。
|
||||||
|
- `npm run lint`:0 个错误、123 条原有警告,退出码 0。
|
||||||
|
- `chart-page-view` 与 `chart-page-hook`:46 通过,退出码 0。
|
||||||
|
- 真机清单沿用任务书里已有的 `docs/testing/chart-ascendant-row-20261015.md`。
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
|
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
|
||||||
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
|
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
|
||||||
| `TASK-chart-ascendant-row-20261015.md` | — | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段,BUG-1201 从未打通(golden 取自引擎内部);状态改「不适用」(推翻 DESIGN 原口径);居中列表头没居中;当前大运 inset 左线去掉。执行方 Grok | 待领取 | `codex/chart-ascendant-row-20261015` |
|
| `TASK-chart-ascendant-row-20261015.md` | `PROGRESS-chart-ascendant-row-20261015.md` | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段(BUG-1352,复发自 BUG-1201);状态改「不适用」;居中列表头对齐;当前大运去掉左边线(BUG-1353) | 待验收 | `codex/chart-ascendant-row-20261015` |
|
||||||
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
|
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
|
||||||
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
|
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
|
||||||
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
|
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
|
||||||
@@ -483,10 +483,10 @@
|
|||||||
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
|
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
|
||||||
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
|
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
|
||||||
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
|
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
|
||||||
| `TASK-api-chart-subprocess-gate-20261013.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 第 1 次打回,见修复单 | `codex/api-chart-subprocess-gate-20261013` |
|
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
|
||||||
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待验收 | `codex/api-chart-subprocess-gate-20261013` |
|
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | — | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待领取 | `codex/api-chart-subprocess-gate-20261013` |
|
||||||
| `TASK-answer-effort-ab-20261013.md` | — | 答题步推理强度开关 + A/B(需模型 key) | 待领取(排队:consult-prep-parallel) | `codex/answer-effort-ab-20261013` |
|
| `TASK-answer-effort-ab-20261013.md` | `PROGRESS-answer-effort-ab-20261013.md` | 答题步推理强度开关 + A/B(需模型 key) | 待验收 | `codex/answer-effort-ab-20261013` |
|
||||||
| `TASK-answer-effort-ab-fix-20261015.md` | — | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140) | 待领取 | `codex/answer-effort-ab-20261013` |
|
| `TASK-answer-effort-ab-fix-20261015.md` | `PROGRESS-answer-effort-ab-fix-20261015.md` | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140),已挪到 `frontend/tests/answer-effort-probe.mts` | 待验收 | `codex/answer-effort-ab-20261013` |
|
||||||
| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` |
|
| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` |
|
||||||
| `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` |
|
| `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` |
|
||||||
| `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` |
|
| `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` |
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 答题步推理强度(2026-10-15)
|
||||||
|
|
||||||
|
没有模型钥匙。下面只是本机拦截到的请求体,不是供应商的接受度,也不是耗时和质量。A/B 未跑。
|
||||||
|
|
||||||
|
命令:在 `frontend` 目录执行 `node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run`。假请求不离开这台机器。模型对象与产品相同:`providerId: deepseek`,`modelId: deepseek-flash`,带 `url` 和假的 `apiKey`。2026-10-15 起脚本在 `frontend/tests/`,不再放在 `frontend/scripts/research/`。
|
||||||
|
|
||||||
|
## 请求体里实际有什么
|
||||||
|
|
||||||
|
| 形状 | thinking | reasoning_effort | max_tokens |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 今天的答题设置,第 0 步和第 1 步 | `{ type: enabled }` | 无 | 24576 |
|
||||||
|
| 旧的低强度设置(没有产品调用方) | `{ type: enabled, reasoningEffort: low }` | 无 | 10240 |
|
||||||
|
| 新开关 `low` / `medium` / `high` / `max`(只在答题步) | `{ type: enabled }` | 与开关相同 | 24576 |
|
||||||
|
| 续写 | `{ type: disabled }` | 无 | 16384 |
|
||||||
|
|
||||||
|
旧的低强度写在 `thinking` 里面,请求体没有 `reasoning_effort`。这证实了任务书 §2.3。记为 BUG-1354,状态 investigating。原先写成 BUG-1351,该号已被数据层占用。线上不用这处设置。
|
||||||
|
|
||||||
|
新开关写在供应商选项的最外层。兼容通道把它写成 `reasoning_effort`。开关不设时,两步请求体与改前逐字节一致,样本在 `frontend/tests/fixtures/answer-effort-switch-20261013.json`。
|
||||||
|
|
||||||
|
## 供应商接受度
|
||||||
|
|
||||||
|
未跑。没有 `DS_KEY` / `BACKTEST_MODEL_KEY`。`low`、`medium`、`high`、`max` 哪几档能用,要等验收时用临时钥匙跑探针的后半段。
|
||||||
|
|
||||||
|
## 固定 10 题
|
||||||
|
|
||||||
|
脚本已能按 `--cases` 跑下面这 10 题,并能按 `--efforts unset,low,medium` 把同一题的几档挨着跑。多档同时跑时并发降为 1,因为强度读的是进程里的环境变量,并行会串档。本机没有钥匙,这 10 题没有跑。
|
||||||
|
|
||||||
|
| # | 盘 | 领域 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | steve_jobs | career |
|
||||||
|
| 2 | barack_obama | parents |
|
||||||
|
| 3 | elizabeth_taylor | marriage |
|
||||||
|
| 4 | marilyn_monroe | parents |
|
||||||
|
| 5 | judy_garland | health |
|
||||||
|
| 6 | edith_piaf | marriage |
|
||||||
|
| 7 | frida_kahlo | health |
|
||||||
|
| 8 | george_w_bush | career |
|
||||||
|
| 9 | zinedine_zidane | career |
|
||||||
|
| 10 | marilyn_monroe | marriage |
|
||||||
|
|
||||||
|
## 耗时与质量
|
||||||
|
|
||||||
|
没有数。空答、截断、rubric、人工四项都没做。默认值维持不设。
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"charts": {
|
|
||||||
"serial_seconds_median": 8.0596,
|
|
||||||
"concurrent_skip_seconds_median": 0.0166,
|
|
||||||
"serial_subprocesses_median": 2,
|
|
||||||
"concurrent_skip_subprocesses_median": 0,
|
|
||||||
"summary_bytes_match": true
|
|
||||||
},
|
|
||||||
"report_forced_subprocess": {
|
|
||||||
"seconds_median": 16.5322,
|
|
||||||
"subprocesses_median": 7
|
|
||||||
},
|
|
||||||
"report_short_circuit": {
|
|
||||||
"seconds_median": 15.3235,
|
|
||||||
"subprocesses_median": 1
|
|
||||||
},
|
|
||||||
"report_bytes_match": true,
|
|
||||||
"schemes": [
|
|
||||||
{
|
|
||||||
"scheme": "O1",
|
|
||||||
"consult_429": "0.40",
|
|
||||||
"report_429": "0.00",
|
|
||||||
"score_429": "0.17",
|
|
||||||
"peak": 2,
|
|
||||||
"touches_bug_717": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"scheme": "O2",
|
|
||||||
"consult_429": "0.00",
|
|
||||||
"report_429": "0.00",
|
|
||||||
"score_429": "0.17",
|
|
||||||
"peak": 3,
|
|
||||||
"touches_bug_717": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"scheme": "O3",
|
|
||||||
"consult_429": "0.30",
|
|
||||||
"report_429": "0.00",
|
|
||||||
"score_429": "0.33",
|
|
||||||
"peak": 2,
|
|
||||||
"touches_bug_717": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"scheme": "O4",
|
|
||||||
"consult_429": "0.00",
|
|
||||||
"report_429": "0.00",
|
|
||||||
"score_429": "0.67",
|
|
||||||
"peak": 2,
|
|
||||||
"touches_bug_717": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"jhora_installed": false
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 合盘排盘与报告子进程 A/B(2026-10-15)
|
|
||||||
|
|
||||||
Windows 本机,工作树 `.venv` 的 `python.exe`。`importlib.util.find_spec("jhora")` 是 `None`。`python3` 指向 Windows 应用商店占位程序,`python3.14` 不存在。Docker 守护进程在,本机没有 `jyotisha-api` 镜像,所以没有进镜像再探一次。本机没有 VedAstro SDK,外网关闭(`VEDASTRO_ENABLE_NETWORK=0`)。没有「真 SDK」一组。
|
|
||||||
|
|
||||||
脚本:`scripts/research/api_chart_subprocess_ab.py`。每组 3 次,取中位数。盘是任务书里的虚构盘(1990-01-01 12:00 与 1992-01-02 12:00,新德里)。星盘缓存 TTL 设为 0。易变字段在比对前丢掉:`generated_at`、`created_at`、`timestamp`、`elapsed_seconds`、`elapsed_ms`、`duration_ms`、`request_id`。原始数字在同目录 `api-chart-subprocess-gate-ab-20261013.json`。
|
|
||||||
|
|
||||||
对照不是另一次 `git show` 出 staging 再导入。星盘对照是本分支的 `_compute_chart_sync`:不带 skip、两张盘串行。报告对照是本分支把「参照引擎已安装」强制打开,从而仍会起子进程;静态校验缓存和解释器查找缓存两边都开着。所以报告耗时差只含那 6 个注定失败的回放进程,不含静态校验少跑的那一次。静态校验「连调两次只跑一次」由单测覆盖。
|
|
||||||
|
|
||||||
## 合盘两张盘
|
|
||||||
|
|
||||||
| | 中位耗时 | 中位 `subprocess.run` 次数 | 盘面摘要 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 对照:不带 skip,串行 | 8.0596 秒 | 2 | — |
|
|
||||||
| 本单:带 skip,并发 | 0.0166 秒 | 0 | 与对照逐字节一致 |
|
|
||||||
|
|
||||||
0.0166 / 8.0596 ≈ 0.2%,低于「不超过对照 10%」的线。子进程从 2 降到 0。任务书写的「4 → 0」是按每张盘 2 个子进程、两张合计 4 个来估的。这台机器上两张盘合计只数到 2 次 `subprocess.run`。不带 skip 的 8 秒里,大部分是没装 SDK 时子进程空等,不是盘面计算本身。
|
|
||||||
|
|
||||||
## 个人长报告(完整数据版,中英,带事实表)
|
|
||||||
|
|
||||||
| | 中位耗时 | 中位 `subprocess.run` 次数 | 中英正文与事实表 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 对照:强制仍起回放子进程 | 16.5322 秒 | 7 | — |
|
|
||||||
| 本单:没装参照引擎就不起 | 15.3235 秒 | 1 | 去掉易变字段后逐字节一致 |
|
|
||||||
|
|
||||||
少 6 次,对应年度回放 3 次和 Tajika 3 次。剩下的 1 次两边都有,短路没有把它去掉。子进程数 1,低于「模拟生产不超过 3」的线。耗时 15.32 秒,不比对照的 16.53 秒差。本机没装 PyJHora,没有再跑「装了参照引擎」的一组;那种环境仍必须走子进程,单测用桩覆盖。
|
|
||||||
|
|
||||||
生产上的收益仍按任务书:大约每份报告 0.5 到 0.9 秒,不是装了参照引擎的机器上看到的约 5 秒。
|
|
||||||
|
|
||||||
## 名额方案模拟(没改规则)
|
|
||||||
|
|
||||||
固定一分钟:2 份报告(每次 13 秒)、20 次咨询(每次 0.2 秒)、6 次校正打分(每次 8 秒)。随机种子 64。并发上限按现状 2。O2、O3、O4 只在脚本里模拟。
|
|
||||||
|
|
||||||
| 方案 | 咨询被拒 | 报告被拒 | 校正被拒 | 同时在跑的最高个数 | 是否碰到「不得加池、不得提高并发」 |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| O1 现状 | 40% | 0% | 17% | 2 | 否 |
|
|
||||||
| O2 咨询单独小池 | 0% | 0% | 17% | 3 | 是,本单不实现 |
|
|
||||||
| O3 咨询最多再等 1.5 秒 | 30% | 0% | 33% | 2 | 否,本单不实现 |
|
|
||||||
| O4 重任务最多占 1 个名额 | 0% | 0% | 67% | 2 | 否,本单不实现 |
|
|
||||||
|
|
||||||
这是一分钟的草图,不是 staging 上的真实频率。咨询少被拒的时候,校正会多被拒(O3、O4)。O2 咨询不再被拒,但同时在跑的个数到了 3,越过了现在的上限。
|
|
||||||
+3
-3
@@ -1178,7 +1178,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
|||||||
| pada | 1–4,居中 |
|
| pada | 1–4,居中 |
|
||||||
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
|
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
|
||||||
|
|
||||||
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。
|
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「不适用」(2026-10-15,与分盘表、报告同一口径;不再写「—」)。数字列 `tabular-nums`,符号列与宿主列居中。
|
||||||
|
|
||||||
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
|
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
|
||||||
|
|
||||||
@@ -1192,7 +1192,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
|||||||
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
|
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
|
||||||
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
|
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
|
||||||
|
|
||||||
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。
|
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化:表头与内容同为居中。本命/月亮八列 markup 冻结,速度、宿主、pada 的表头用列选择器居中,不改 markup,也不把宫位那一列居中。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头带 `chart-page-planet-mid`。不能全局覆盖 `chart-page-planet-mid` 去改宫位列。
|
||||||
|
|
||||||
### 三套坐标系
|
### 三套坐标系
|
||||||
|
|
||||||
@@ -1214,7 +1214,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
|||||||
|
|
||||||
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
|
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
|
||||||
|
|
||||||
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。
|
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。
|
||||||
|
|
||||||
### 西洋盘 Tab(2026-09-25)
|
### 西洋盘 Tab(2026-09-25)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
* Usage (from frontend/, Node 22):
|
* Usage (from frontend/, Node 22):
|
||||||
* DS_KEY=... ./node_modules/.bin/tsx scripts/research/consult-biography-backtest.mts \
|
* DS_KEY=... ./node_modules/.bin/tsx scripts/research/consult-biography-backtest.mts \
|
||||||
* [--out <dir>] [--repeats 2] [--figures a,b] [--domains parents,marriage,health,career] \
|
* [--out <dir>] [--repeats 2] [--figures a,b] [--domains parents,marriage,health,career] \
|
||||||
|
* [--cases steve_jobs:career,barack_obama:parents] [--efforts unset,low,medium] \
|
||||||
* [--model deepseek-flash] [--base-url https://api.deepseek.com] [--concurrency 4] [--dump-cards]
|
* [--model deepseek-flash] [--base-url https://api.deepseek.com] [--concurrency 4] [--dump-cards]
|
||||||
* [--correction barack_obama,marilyn_monroe] [--correction-text 我爸其实没怎么管过我]
|
* [--correction barack_obama,marilyn_monroe] [--correction-text 我爸其实没怎么管过我]
|
||||||
* [--correction-domain career] [--correction-texts "zinedine_zidane=我是足球运动员|barack_obama=我是政治家"]
|
* [--correction-domain career] [--correction-texts "zinedine_zidane=我是足球运动员|barack_obama=我是政治家"]
|
||||||
@@ -142,6 +143,8 @@ function parseArgs(argv: readonly string[]) {
|
|||||||
const at = pair.indexOf("=");
|
const at = pair.indexOf("=");
|
||||||
return [pair.slice(0, at), pair.slice(at + 1)];
|
return [pair.slice(0, at), pair.slice(at + 1)];
|
||||||
})) as Record<string, string>,
|
})) as Record<string, string>,
|
||||||
|
cases: value("cases")?.split(",").map((item) => item.trim()).filter(Boolean) ?? null,
|
||||||
|
efforts: value("efforts")?.split(",").map((item) => item.trim()).filter(Boolean) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +273,15 @@ function model(args: ReturnType<typeof parseArgs>, apiKey: string): ResolvedLang
|
|||||||
|
|
||||||
/** A follow-up turn in the same session: the earlier question and answer are the history. */
|
/** A follow-up turn in the same session: the earlier question and answer are the history. */
|
||||||
type FollowUp = { priorQuestion: string; priorAnswer: string };
|
type FollowUp = { priorQuestion: string; priorAnswer: string };
|
||||||
type Job = { figure: GoldenFigure; domain: string; question: string; run: number; followUp?: FollowUp };
|
type Job = {
|
||||||
|
figure: GoldenFigure;
|
||||||
|
domain: string;
|
||||||
|
question: string;
|
||||||
|
run: number;
|
||||||
|
followUp?: FollowUp;
|
||||||
|
/** When set, this job overrides CONSULT_ANSWER_REASONING_EFFORT for its own stream. */
|
||||||
|
effort?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Usage = { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number };
|
type Usage = { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number };
|
||||||
|
|
||||||
@@ -316,6 +327,22 @@ function userTurn(question: string, followUp?: FollowUp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runJob(job: Job, resolved: ResolvedLanguageModel) {
|
async function runJob(job: Job, resolved: ResolvedLanguageModel) {
|
||||||
|
const previousEffort = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
if (job.effort !== undefined) {
|
||||||
|
if (job.effort === "unset" || job.effort === "") delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
else process.env.CONSULT_ANSWER_REASONING_EFFORT = job.effort;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await runJobOnce(job, resolved);
|
||||||
|
} finally {
|
||||||
|
if (job.effort !== undefined) {
|
||||||
|
if (previousEffort === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previousEffort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJobOnce(job: Job, resolved: ResolvedLanguageModel) {
|
||||||
const state = createConsultationRuntimeState();
|
const state = createConsultationRuntimeState();
|
||||||
const executed: string[] = [];
|
const executed: string[] = [];
|
||||||
const ctx = agentContext(job, state, executed);
|
const ctx = agentContext(job, state, executed);
|
||||||
@@ -333,7 +360,14 @@ async function runJob(job: Job, resolved: ResolvedLanguageModel) {
|
|||||||
maxSteps: AGENT_MAX_STEPS,
|
maxSteps: AGENT_MAX_STEPS,
|
||||||
...consultationGenerationSettings(resolved.model),
|
...consultationGenerationSettings(resolved.model),
|
||||||
};
|
};
|
||||||
const natalStreamOptions = { ...streamOptions, prepareStep: consultationNatalPrepareStep };
|
const natalStreamOptions = {
|
||||||
|
...streamOptions,
|
||||||
|
prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({
|
||||||
|
stepNumber: input.stepNumber,
|
||||||
|
answerReady: state.consultationToolCompleted,
|
||||||
|
model: resolved.model,
|
||||||
|
}),
|
||||||
|
};
|
||||||
const usages: Promise<unknown>[] = [];
|
const usages: Promise<unknown>[] = [];
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let output: string | null = null;
|
let output: string | null = null;
|
||||||
@@ -417,6 +451,17 @@ async function runJob(job: Job, resolved: ResolvedLanguageModel) {
|
|||||||
methodologySections: state.methodologySectionCount,
|
methodologySections: state.methodologySectionCount,
|
||||||
lookups: state.evidenceLookupCallCount,
|
lookups: state.evidenceLookupCallCount,
|
||||||
finish: state.composeFinishReason ?? state.modelFinishReason ?? null,
|
finish: state.composeFinishReason ?? state.modelFinishReason ?? null,
|
||||||
|
effort: process.env.CONSULT_ANSWER_REASONING_EFFORT?.trim() || null,
|
||||||
|
answerReasoningMs: state.answerReasoningMs ?? null,
|
||||||
|
steps: [0, 1].map((index) => {
|
||||||
|
const step = state.modelStepTimings?.find((item) => item.index === index);
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
seconds: step?.durationMs == null ? null : step.durationMs / 1000,
|
||||||
|
reasoningTokens: step?.reasoningTokens ?? null,
|
||||||
|
outputTokens: step?.outputTokens ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +522,18 @@ console.info = (...items: unknown[]) => {
|
|||||||
productInfo(...items);
|
productInfo(...items);
|
||||||
};
|
};
|
||||||
|
|
||||||
type Row = { figure: string; domain: string; run: number; chars: number; seconds: number; checks: ReturnType<typeof deterministicChecks> };
|
type Row = {
|
||||||
|
figure: string;
|
||||||
|
domain: string;
|
||||||
|
run: number;
|
||||||
|
chars: number;
|
||||||
|
seconds: number;
|
||||||
|
checks: ReturnType<typeof deterministicChecks>;
|
||||||
|
empty?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
finish?: string | null;
|
||||||
|
steps?: { seconds: number | null; reasoningTokens: number | null; outputTokens: number | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
function writeSummary(out: string, summary: Json & { rows: Row[] }, modelId: string) {
|
function writeSummary(out: string, summary: Json & { rows: Row[] }, modelId: string) {
|
||||||
const rows = summary.rows;
|
const rows = summary.rows;
|
||||||
@@ -488,13 +544,13 @@ function writeSummary(out: string, summary: Json & { rows: Row[] }, modelId: str
|
|||||||
writeFileSync(join(out, "summary.md"), [
|
writeFileSync(join(out, "summary.md"), [
|
||||||
`# 名人生平回测 · ${modelId}`,
|
`# 名人生平回测 · ${modelId}`,
|
||||||
"",
|
"",
|
||||||
`开始 ${summary.started_at},${rows.length} 份,墙钟 ${Number(summary.wall_seconds).toFixed(0)} s,失败 ${summary.failures} 份。`,
|
`开始 ${summary.started_at},${rows.length} 份,墙钟 ${Number(summary.wall_seconds).toFixed(0)} s,失败 ${summary.failures} 份。推理强度:${String(summary.reasoning_effort ?? "不设")}。`,
|
||||||
`用量合计:${JSON.stringify(summary.usage)}`,
|
`用量合计:${JSON.stringify(summary.usage)}`,
|
||||||
`确定性检查:禁句 ${summary.banned_hits},性别代词(婚恋/子女)${summary.pronoun_hits},must_not 原文 ${summary.must_not_literal_hits}。判断类评分需逐份阅读。`,
|
`确定性检查:禁句 ${summary.banned_hits},性别代词(婚恋/子女)${summary.pronoun_hits},must_not 原文 ${summary.must_not_literal_hits}。判断类评分需逐份阅读。`,
|
||||||
"",
|
"",
|
||||||
"| 名人 | 领域 | run | 字数 | 秒 | 禁句 | 代词 | must_not |",
|
"| 名人 | 领域 | run | 字数 | 秒 | 空答 | 截断 | 第0步秒 | 第1步秒 | 第1步推理token | 结束 |",
|
||||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||||
...rows.map((row) => `| ${row.figure} | ${row.domain} | ${row.run} | ${row.chars} | ${row.seconds.toFixed(0)} | ${row.checks.banned.length} | ${row.checks.pronouns.length} | ${row.checks.literalHits.length} |`),
|
...rows.map((row) => `| ${row.figure} | ${row.domain} | ${row.run} | ${row.chars} | ${row.seconds.toFixed(0)} | ${row.empty ? "是" : ""} | ${row.truncated ? "是" : ""} | ${row.steps?.[0]?.seconds ?? ""} | ${row.steps?.[1]?.seconds ?? ""} | ${row.steps?.[1]?.reasoningTokens ?? ""} | ${row.finish ?? ""} |`),
|
||||||
"",
|
"",
|
||||||
"## must_not 原文命中",
|
"## must_not 原文命中",
|
||||||
"",
|
"",
|
||||||
@@ -529,11 +585,28 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const questions = new Map(golden.routes.map((route) => [route.domain, route.question]));
|
const questions = new Map(golden.routes.map((route) => [route.domain, route.question]));
|
||||||
const figures = golden.figures.filter((figure) => !args.figures || args.figures.includes(figure.id));
|
const figures = golden.figures.filter((figure) => !args.figures || args.figures.includes(figure.id));
|
||||||
const jobs: Job[] = figures.flatMap((figure) => args.domains.flatMap((domain) => {
|
const pairs = args.cases
|
||||||
const question = questions.get(domain);
|
? args.cases.map((spec) => {
|
||||||
if (!question) throw new Error(`no golden question for domain ${domain}`);
|
const splitAt = spec.indexOf(":");
|
||||||
return Array.from({ length: args.repeats }, (_, index) => ({ figure, domain, question, run: index + 1 }));
|
const figureId = spec.slice(0, splitAt);
|
||||||
}));
|
const domain = spec.slice(splitAt + 1);
|
||||||
|
const figure = golden.figures.find((item) => item.id === figureId);
|
||||||
|
const question = questions.get(domain);
|
||||||
|
if (!figure || !question) throw new Error(`unknown case ${spec}`);
|
||||||
|
return { figure, domain, question };
|
||||||
|
})
|
||||||
|
: figures.flatMap((figure) => args.domains.flatMap((domain) => {
|
||||||
|
const question = questions.get(domain);
|
||||||
|
if (!question) throw new Error(`no golden question for domain ${domain}`);
|
||||||
|
return [{ figure, domain, question }];
|
||||||
|
}));
|
||||||
|
const efforts = args.efforts;
|
||||||
|
const jobs: Job[] = pairs.flatMap((pair) => Array.from({ length: args.repeats }, (_, index) => {
|
||||||
|
const run = index + 1;
|
||||||
|
if (!efforts) return [{ ...pair, run }];
|
||||||
|
return efforts.map((effort) => ({ ...pair, run, effort }));
|
||||||
|
}).flat());
|
||||||
|
const concurrency = efforts && efforts.length > 1 ? 1 : args.concurrency;
|
||||||
mkdirSync(args.out, { recursive: true });
|
mkdirSync(args.out, { recursive: true });
|
||||||
if (args.dumpCards) {
|
if (args.dumpCards) {
|
||||||
await dumpCards(jobs, args.out);
|
await dumpCards(jobs, args.out);
|
||||||
@@ -543,12 +616,12 @@ async function main() {
|
|||||||
if (!apiKey) throw new Error("Set DS_KEY (or BACKTEST_MODEL_KEY); without a key the backtest is an environment gap.");
|
if (!apiKey) throw new Error("Set DS_KEY (or BACKTEST_MODEL_KEY); without a key the backtest is an environment gap.");
|
||||||
const resolved = model(args, apiKey);
|
const resolved = model(args, apiKey);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const rows = await pool(jobs, args.concurrency, async (job) => {
|
const rows = await pool(jobs, concurrency, async (job) => {
|
||||||
let result: Awaited<ReturnType<typeof runJob>>;
|
let result: Awaited<ReturnType<typeof runJob>>;
|
||||||
try {
|
try {
|
||||||
result = await runJob(job, resolved);
|
result = await runJob(job, resolved);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = { output: "", failure: error instanceof Error ? error.message : String(error), executed: [], seconds: 0, usage: {}, cardChars: null, visibleChars: null, methodologySections: 0, lookups: 0, finish: null };
|
result = { output: "", failure: error instanceof Error ? error.message : String(error), executed: [], seconds: 0, usage: {}, cardChars: null, visibleChars: null, methodologySections: 0, lookups: 0, finish: null, effort: job.effort ?? null, answerReasoningMs: null, steps: [{ index: 0, seconds: null, reasoningTokens: null, outputTokens: null }, { index: 1, seconds: null, reasoningTokens: null, outputTokens: null }] };
|
||||||
}
|
}
|
||||||
const mustNot = rubric.figures.find((item) => item.id === job.figure.id)?.domains[job.domain]?.must_not ?? [];
|
const mustNot = rubric.figures.find((item) => item.id === job.figure.id)?.domains[job.domain]?.must_not ?? [];
|
||||||
const checks = deterministicChecks(result.output, job.domain, mustNot);
|
const checks = deterministicChecks(result.output, job.domain, mustNot);
|
||||||
@@ -573,7 +646,18 @@ async function main() {
|
|||||||
"",
|
"",
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
console.log(`${job.figure.id} ${job.domain} run${job.run}: ${result.output.length} chars, ${result.seconds.toFixed(0)} s${result.failure ? `, FAILED ${result.failure}` : ""}`);
|
console.log(`${job.figure.id} ${job.domain} run${job.run}: ${result.output.length} chars, ${result.seconds.toFixed(0)} s${result.failure ? `, FAILED ${result.failure}` : ""}`);
|
||||||
return { figure: job.figure.id, domain: job.domain, run: job.run, chars: result.output.length, ...result, output: undefined, checks };
|
return {
|
||||||
|
figure: job.figure.id,
|
||||||
|
domain: job.domain,
|
||||||
|
run: job.run,
|
||||||
|
chars: result.output.length,
|
||||||
|
empty: result.output.trim() === "",
|
||||||
|
truncated: result.finish === "length" || (result.failure ?? "").includes("answer_truncated"),
|
||||||
|
outputTokens: result.usage.outputTokens ?? null,
|
||||||
|
...result,
|
||||||
|
output: undefined,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const corrections = await pool(
|
const corrections = await pool(
|
||||||
jobs.filter((job) => args.correctionDomains.includes(job.domain)
|
jobs.filter((job) => args.correctionDomains.includes(job.domain)
|
||||||
@@ -589,7 +673,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
result = await runJob(job, resolved);
|
result = await runJob(job, resolved);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = { output: "", failure: error instanceof Error ? error.message : String(error), executed: [], seconds: 0, usage: {}, cardChars: null, visibleChars: null, methodologySections: 0, lookups: 0, finish: null };
|
result = { output: "", failure: error instanceof Error ? error.message : String(error), executed: [], seconds: 0, usage: {}, cardChars: null, visibleChars: null, methodologySections: 0, lookups: 0, finish: null, effort: job.effort ?? null, answerReasoningMs: null, steps: [{ index: 0, seconds: null, reasoningTokens: null, outputTokens: null }, { index: 1, seconds: null, reasoningTokens: null, outputTokens: null }] };
|
||||||
}
|
}
|
||||||
const file = join(args.out, job.figure.id, `${job.domain}-correction-run${job.run}.md`);
|
const file = join(args.out, job.figure.id, `${job.domain}-correction-run${job.run}.md`);
|
||||||
writeFileSync(file, [
|
writeFileSync(file, [
|
||||||
@@ -619,6 +703,9 @@ async function main() {
|
|||||||
jobs: rows.length,
|
jobs: rows.length,
|
||||||
failures: rows.filter((row) => row.failure || row.chars === 0).length,
|
failures: rows.filter((row) => row.failure || row.chars === 0).length,
|
||||||
usage: total,
|
usage: total,
|
||||||
|
reasoning_effort: process.env.CONSULT_ANSWER_REASONING_EFFORT?.trim() || null,
|
||||||
|
efforts: args.efforts,
|
||||||
|
concurrency,
|
||||||
gaps: GAPS,
|
gaps: GAPS,
|
||||||
rows,
|
rows,
|
||||||
corrections,
|
corrections,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ import {
|
|||||||
createConsultationRunClock,
|
createConsultationRunClock,
|
||||||
consultationContinueGenerationSettings,
|
consultationContinueGenerationSettings,
|
||||||
consultationGenerationSettings,
|
consultationGenerationSettings,
|
||||||
|
consultationGeneralPrepareStep,
|
||||||
|
consultAnswerEffortProviderOptions,
|
||||||
consultationNatalPrepareStep,
|
consultationNatalPrepareStep,
|
||||||
consultationWindowPrepareStep,
|
consultationWindowPrepareStep,
|
||||||
createConsultationAgentContext,
|
createConsultationAgentContext,
|
||||||
@@ -1288,6 +1290,8 @@ export async function POST(request: Request) {
|
|||||||
prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({
|
prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({
|
||||||
stepNumber: input.stepNumber,
|
stepNumber: input.stepNumber,
|
||||||
rectificationOffer: true,
|
rectificationOffer: true,
|
||||||
|
answerReady: state.consultationToolCompleted,
|
||||||
|
model: selectedModel.model,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const windowStreamOptions = {
|
const windowStreamOptions = {
|
||||||
@@ -1295,6 +1299,15 @@ export async function POST(request: Request) {
|
|||||||
prepareStep: (input: { stepNumber: number }) => consultationWindowPrepareStep({
|
prepareStep: (input: { stepNumber: number }) => consultationWindowPrepareStep({
|
||||||
stepNumber: input.stepNumber,
|
stepNumber: input.stepNumber,
|
||||||
rectificationOffer: true,
|
rectificationOffer: true,
|
||||||
|
answerReady: state.consultationToolCompleted,
|
||||||
|
model: selectedModel.model,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const generalStreamOptions = {
|
||||||
|
...streamOptions,
|
||||||
|
prepareStep: (input: { stepNumber: number }) => consultationGeneralPrepareStep({
|
||||||
|
stepNumber: input.stepNumber,
|
||||||
|
model: selectedModel.model,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const answerPhaseSignal = runClock.answerSignal;
|
const answerPhaseSignal = runClock.answerSignal;
|
||||||
@@ -1305,10 +1318,10 @@ export async function POST(request: Request) {
|
|||||||
agent: {
|
agent: {
|
||||||
stream: (
|
stream: (
|
||||||
messages: typeof baseMessages,
|
messages: typeof baseMessages,
|
||||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions,
|
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions | typeof generalStreamOptions,
|
||||||
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
|
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
|
||||||
},
|
},
|
||||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions,
|
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions | typeof generalStreamOptions = streamOptions,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await agent.stream(baseMessages, options);
|
const result = await agent.stream(baseMessages, options);
|
||||||
@@ -1369,14 +1382,14 @@ export async function POST(request: Request) {
|
|||||||
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
|
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
|
||||||
state.workflowReceipt = workflowReceipt;
|
state.workflowReceipt = workflowReceipt;
|
||||||
const agent = getGeneralJyotishAgent(selectedModel);
|
const agent = getGeneralJyotishAgent(selectedModel);
|
||||||
const result = await streamWithOverflowRetry(agent);
|
const result = await streamWithOverflowRetry(agent, generalStreamOptions);
|
||||||
// This mode has no calculation to require and no chart method to bind,
|
// This mode has no calculation to require and no chart method to bind,
|
||||||
// so there is no contract for a retry to repair.
|
// so there is no contract for a retry to repair.
|
||||||
const retryForAnswer = async (retryHint?: string) => {
|
const retryForAnswer = async (retryHint?: string) => {
|
||||||
const retried = await agent.stream([
|
const retried = await agent.stream([
|
||||||
...baseMessages,
|
...baseMessages,
|
||||||
{ role: "user" as const, content: `上一轮没有输出任何回答文本。请直接给出这个问题的回答,不要只说明过程。${retryHint ? `\n${retryHint}` : ""}` },
|
{ role: "user" as const, content: `上一轮没有输出任何回答文本。请直接给出这个问题的回答,不要只说明过程。${retryHint ? `\n${retryHint}` : ""}` },
|
||||||
], { ...streamOptions, abortSignal: answerPhaseSignal() });
|
], { ...generalStreamOptions, abortSignal: answerPhaseSignal() });
|
||||||
usages.push(retried.totalUsage);
|
usages.push(retried.totalUsage);
|
||||||
return retried.fullStream;
|
return retried.fullStream;
|
||||||
};
|
};
|
||||||
@@ -1472,7 +1485,11 @@ export async function POST(request: Request) {
|
|||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: `服务器窗口计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。${retryHint ? `\n${retryHint}` : ""}`,
|
content: `服务器窗口计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。${retryHint ? `\n${retryHint}` : ""}`,
|
||||||
},
|
},
|
||||||
], { ...streamOptions, abortSignal: answerPhaseSignal() });
|
], {
|
||||||
|
...streamOptions,
|
||||||
|
abortSignal: answerPhaseSignal(),
|
||||||
|
...consultAnswerEffortProviderOptions(selectedModel.model),
|
||||||
|
});
|
||||||
usages.push(retried.totalUsage);
|
usages.push(retried.totalUsage);
|
||||||
return retried.fullStream;
|
return retried.fullStream;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,10 +183,8 @@ export async function POST(request: Request) {
|
|||||||
synastryBirthPayload(globalBirthProfileFromAccountRow(selfResolved.row)),
|
synastryBirthPayload(globalBirthProfileFromAccountRow(selfResolved.row)),
|
||||||
synastryBirthPayload(globalBirthProfileFromAccountRow(partnerResolved.row)),
|
synastryBirthPayload(globalBirthProfileFromAccountRow(partnerResolved.row)),
|
||||||
]);
|
]);
|
||||||
const [selfChart, partnerChart] = await Promise.all([
|
const selfChart = await postPython("/api/chart", selfPayload);
|
||||||
postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true }),
|
const partnerChart = await postPython("/api/chart", partnerPayload);
|
||||||
postPython("/api/chart", { ...partnerPayload, skip_vedastro_main_entry_overview: true }),
|
|
||||||
]);
|
|
||||||
const relationshipType = parsed.data.relationshipType === "business" || parsed.data.relationshipType === "family" || parsed.data.relationshipType === "general"
|
const relationshipType = parsed.data.relationshipType === "business" || parsed.data.relationshipType === "family" || parsed.data.relationshipType === "general"
|
||||||
? parsed.data.relationshipType
|
? parsed.data.relationshipType
|
||||||
: "romance";
|
: "romance";
|
||||||
@@ -212,20 +210,18 @@ export async function POST(request: Request) {
|
|||||||
message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。",
|
message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const [selfD9, partnerD9] = await Promise.all([
|
const selfD9 = await postPython("/api/varga_full", {
|
||||||
postPython("/api/varga_full", {
|
...selfPayload,
|
||||||
...selfPayload,
|
planets: selfChart.planets,
|
||||||
planets: selfChart.planets,
|
ascendant: selfChart.ascendant,
|
||||||
ascendant: selfChart.ascendant,
|
divisions: ["D9"],
|
||||||
divisions: ["D9"],
|
});
|
||||||
}),
|
const partnerD9 = await postPython("/api/varga_full", {
|
||||||
postPython("/api/varga_full", {
|
...partnerPayload,
|
||||||
...partnerPayload,
|
planets: partnerChart.planets,
|
||||||
planets: partnerChart.planets,
|
ascendant: partnerChart.ascendant,
|
||||||
ascendant: partnerChart.ascendant,
|
divisions: ["D9"],
|
||||||
divisions: ["D9"],
|
});
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const synastry = await postPython("/api/synastry", {
|
const synastry = await postPython("/api/synastry", {
|
||||||
male_moon: moonLongitude(selfChart),
|
male_moon: moonLongitude(selfChart),
|
||||||
female_moon: moonLongitude(partnerChart),
|
female_moon: moonLongitude(partnerChart),
|
||||||
|
|||||||
@@ -5655,11 +5655,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
.chart-page-planet-glyph.is-node,
|
.chart-page-planet-glyph.is-node,
|
||||||
.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); }
|
.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); }
|
||||||
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
|
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
|
||||||
|
.chart-page-planet-table th.chart-page-planet-mid,
|
||||||
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
|
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
|
||||||
/* The natal/Moon eight-column markup stays frozen; only its house column changes. */
|
/* The natal/Moon eight-column markup stays frozen; only its house column changes.
|
||||||
|
Speed, nakshatra lord and pada stay centered, including their headers. */
|
||||||
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) {
|
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(3),
|
||||||
|
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(6),
|
||||||
|
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(7) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
/* Figures line up column-wise instead of drifting with glyph width. */
|
/* Figures line up column-wise instead of drifting with glyph width. */
|
||||||
.chart-page-planet-num { font-variant-numeric: tabular-nums; }
|
.chart-page-planet-num { font-variant-numeric: tabular-nums; }
|
||||||
.chart-page-boundary {
|
.chart-page-boundary {
|
||||||
@@ -5774,7 +5781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
.chart-page-dasha-period.is-current {
|
.chart-page-dasha-period.is-current {
|
||||||
background: var(--color-selected);
|
background: var(--color-selected);
|
||||||
border-color: var(--color-border-strong);
|
border-color: var(--color-border-strong);
|
||||||
box-shadow: inset 2px 0 0 var(--color-focus);
|
|
||||||
}
|
}
|
||||||
.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); }
|
.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); }
|
||||||
.chart-page-dasha-period.is-zero span { margin-left: auto; }
|
.chart-page-dasha-period.is-zero span { margin-left: auto; }
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The ascendant is a chart point: nakshatra, lord and pada come from the
|
* The ascendant is a chart point: nakshatra, lord and pada come from the
|
||||||
* engine (BUG-1201); it has no dignity, so the status cell stays empty.
|
* engine (BUG-1201); it has no dignity, so the status cell says 不适用.
|
||||||
* `house` is 1 except on the Moon chart, which counts from the Moon.
|
* `house` is 1 except on the Moon chart, which counts from the Moon.
|
||||||
*/
|
*/
|
||||||
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
|
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
|
||||||
@@ -96,7 +96,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow;
|
|||||||
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
|
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada ?? "—"}</td>
|
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada ?? "—"}</td>
|
||||||
<td>—</td>
|
<td>不适用</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,8 +192,8 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
|
|||||||
<th scope="col">分盘星座</th>
|
<th scope="col">分盘星座</th>
|
||||||
<th scope="col">宫位</th>
|
<th scope="col">宫位</th>
|
||||||
<th scope="col">本命星座</th>
|
<th scope="col">本命星座</th>
|
||||||
{columns.supportsDignity && <th scope="col">庙旺</th>}
|
{columns.supportsDignity && <th scope="col" className="chart-page-planet-mid">庙旺</th>}
|
||||||
{columns.supportsVargottama && <th scope="col">同位</th>}
|
{columns.supportsVargottama && <th scope="col" className="chart-page-planet-mid">同位</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -219,7 +219,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">行星</th>
|
<th scope="col">行星</th>
|
||||||
<th scope="col">今天的位置</th>
|
<th scope="col">今天的位置</th>
|
||||||
<th scope="col">速度</th>
|
<th scope="col" className="chart-page-planet-mid">速度</th>
|
||||||
<th scope="col">落本命</th>
|
<th scope="col">落本命</th>
|
||||||
<th scope="col">本命同星</th>
|
<th scope="col">本命同星</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -257,7 +257,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
|
|||||||
<th scope="col">黄道经度</th>
|
<th scope="col">黄道经度</th>
|
||||||
<th scope="col">星座宫</th>
|
<th scope="col">星座宫</th>
|
||||||
<th scope="col">Bhava 宫</th>
|
<th scope="col">Bhava 宫</th>
|
||||||
<th scope="col">换宫</th>
|
<th scope="col" className="chart-page-planet-mid">换宫</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS;
|
|||||||
|
|
||||||
export type ThinkingMode = "enabled" | "disabled";
|
export type ThinkingMode = "enabled" | "disabled";
|
||||||
export type ReasoningEffort = "low" | "medium" | "high";
|
export type ReasoningEffort = "low" | "medium" | "high";
|
||||||
|
/** Top-level OpenAI-compatible `reasoning_effort`, including DeepSeek's `max`. */
|
||||||
|
export type ProviderReasoningEffort = "low" | "medium" | "high" | "max";
|
||||||
|
|
||||||
export type PromptCacheUsage = Readonly<{
|
export type PromptCacheUsage = Readonly<{
|
||||||
readTokens: number;
|
readTokens: number;
|
||||||
@@ -100,6 +102,8 @@ export function agentGenerationSettings(
|
|||||||
answerTokens?: number;
|
answerTokens?: number;
|
||||||
thinkingTokens?: number;
|
thinkingTokens?: number;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
/** Written beside `thinking`, not inside it, so compatible gateways emit `reasoning_effort`. */
|
||||||
|
providerReasoningEffort?: ProviderReasoningEffort;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const thinkingMode: ThinkingMode = options.thinking ?? "disabled";
|
const thinkingMode: ThinkingMode = options.thinking ?? "disabled";
|
||||||
@@ -110,6 +114,9 @@ export function agentGenerationSettings(
|
|||||||
? { reasoningEffort: options.reasoningEffort }
|
? { reasoningEffort: options.reasoningEffort }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
...(options.providerReasoningEffort
|
||||||
|
? { reasoningEffort: options.providerReasoningEffort }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const providerId = typeof model === "string"
|
const providerId = typeof model === "string"
|
||||||
? model
|
? model
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati
|
|||||||
import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts";
|
import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts";
|
||||||
import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts";
|
import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts";
|
||||||
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
|
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
|
||||||
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
|
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS, type ProviderReasoningEffort } from "../lib/agent-generation-settings.ts";
|
||||||
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
|
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
|
||||||
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
|
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
|
||||||
import {
|
import {
|
||||||
@@ -176,6 +176,45 @@ export const CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID = "read-consultation-evidence"
|
|||||||
export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
|
export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
|
||||||
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
|
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer-step reasoning effort. Unset or blank keeps today's request body.
|
||||||
|
* An illegal value warns once and is treated as unset.
|
||||||
|
*/
|
||||||
|
const CONSULT_ANSWER_REASONING_EFFORTS = ["low", "medium", "high", "max"] as const;
|
||||||
|
let consultAnswerReasoningEffortWarned = false;
|
||||||
|
|
||||||
|
export function consultAnswerReasoningEffort(): ProviderReasoningEffort | undefined {
|
||||||
|
const raw = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
if (raw === undefined || raw.trim() === "") return undefined;
|
||||||
|
const value = raw.trim();
|
||||||
|
if ((CONSULT_ANSWER_REASONING_EFFORTS as readonly string[]).includes(value)) {
|
||||||
|
return value as ProviderReasoningEffort;
|
||||||
|
}
|
||||||
|
if (!consultAnswerReasoningEffortWarned) {
|
||||||
|
consultAnswerReasoningEffortWarned = true;
|
||||||
|
console.warn(`CONSULT_ANSWER_REASONING_EFFORT=${value} is not low, medium, high, or max; leaving reasoning effort unset.`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnswerEffortInput = {
|
||||||
|
answerReady?: boolean;
|
||||||
|
answerFromStart?: boolean;
|
||||||
|
model?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function answerEffortOptions(input: AnswerEffortInput) {
|
||||||
|
const effort = consultAnswerReasoningEffort();
|
||||||
|
const answerStep = input.answerFromStart === true || input.answerReady === true;
|
||||||
|
if (!effort || !answerStep) return {};
|
||||||
|
return {
|
||||||
|
providerOptions: agentGenerationSettings(input.model, {
|
||||||
|
thinking: "enabled",
|
||||||
|
providerReasoningEffort: effort,
|
||||||
|
}).providerOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 0 narrows with activeTools and leaves toolChoice auto. Thinking-mode
|
* Step 0 narrows with activeTools and leaves toolChoice auto. Thinking-mode
|
||||||
* providers reject named and required toolChoice (BUG-282, BUG-937).
|
* providers reject named and required toolChoice (BUG-282, BUG-937).
|
||||||
@@ -185,35 +224,77 @@ export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation
|
|||||||
* Later steps stay auto.
|
* Later steps stay auto.
|
||||||
*
|
*
|
||||||
* Window and general agents must not share the natal hook.
|
* Window and general agents must not share the natal hook.
|
||||||
|
*
|
||||||
|
* The answer step is any step that already holds a calculation (`answerReady`,
|
||||||
|
* the same flag as the answer clock) or a no-tool public-daily turn
|
||||||
|
* (`answerFromStart`). A retry starts a new stream at step 0 with the
|
||||||
|
* calculation already in hand, so the step number is not part of the check.
|
||||||
|
* The main loop's step 0 still has `answerReady` false.
|
||||||
*/
|
*/
|
||||||
export function consultationNatalPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
|
type ConsultPrepareInput = {
|
||||||
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
|
rectificationOffer?: boolean;
|
||||||
|
answerReady?: boolean;
|
||||||
|
answerFromStart?: boolean;
|
||||||
|
model?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function consultationNatalPrepareStep(input: { stepNumber: 0 } & ConsultPrepareInput): {
|
||||||
|
activeTools: string[];
|
||||||
|
toolChoice: "auto";
|
||||||
|
};
|
||||||
|
export function consultationNatalPrepareStep(input: { stepNumber: number } & ConsultPrepareInput): {
|
||||||
|
toolChoice: "auto";
|
||||||
|
activeTools?: string[];
|
||||||
|
};
|
||||||
|
export function consultationNatalPrepareStep(input: { stepNumber: number } & ConsultPrepareInput) {
|
||||||
|
const effort = answerEffortOptions(input);
|
||||||
|
if (input.stepNumber !== 0) return { toolChoice: "auto" as const, ...effort };
|
||||||
if (!input.rectificationOffer) {
|
if (!input.rectificationOffer) {
|
||||||
return {
|
return {
|
||||||
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
|
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
|
||||||
toolChoice: "auto" as const,
|
toolChoice: "auto" as const,
|
||||||
|
...effort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
|
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
|
||||||
toolChoice: "auto" as const,
|
toolChoice: "auto" as const,
|
||||||
|
...effort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function consultationWindowPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
|
export function consultationWindowPrepareStep(input: {
|
||||||
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
|
stepNumber: number;
|
||||||
|
rectificationOffer?: boolean;
|
||||||
|
answerReady?: boolean;
|
||||||
|
answerFromStart?: boolean;
|
||||||
|
model?: unknown;
|
||||||
|
}) {
|
||||||
|
const effort = answerEffortOptions(input);
|
||||||
|
if (input.stepNumber !== 0) return { toolChoice: "auto" as const, ...effort };
|
||||||
if (!input.rectificationOffer) {
|
if (!input.rectificationOffer) {
|
||||||
return {
|
return {
|
||||||
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
|
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
|
||||||
toolChoice: "auto" as const,
|
toolChoice: "auto" as const,
|
||||||
|
...effort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
|
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
|
||||||
toolChoice: "auto" as const,
|
toolChoice: "auto" as const,
|
||||||
|
...effort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function consultAnswerEffortProviderOptions(model?: unknown) {
|
||||||
|
return answerEffortOptions({ answerReady: true, model });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public daily has no chart tool. Its first step is already the answer. */
|
||||||
|
export function consultationGeneralPrepareStep(input: { stepNumber: number; model?: unknown }) {
|
||||||
|
return answerEffortOptions({ ...input, answerFromStart: true });
|
||||||
|
}
|
||||||
|
|
||||||
const CONSULTATION_DOMAIN_DURATION_MS = 31_000;
|
const CONSULTATION_DOMAIN_DURATION_MS = 31_000;
|
||||||
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
|
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
|
||||||
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
|
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Request-body probe for issue #65.
|
||||||
|
*
|
||||||
|
* --dry-run needs no model key. It sends one agent.stream per shape through
|
||||||
|
* Mastra's OpenAI-compatible channel and prints the thinking / reasoning_effort
|
||||||
|
* / max_tokens fields of each request. A fake fetch never leaves the machine.
|
||||||
|
*
|
||||||
|
* Shapes:
|
||||||
|
* (a) consultationGenerationSettings (today's answer settings)
|
||||||
|
* (b) consultationSliceGenerationSettings (nested reasoningEffort, no caller)
|
||||||
|
* (c) providerReasoningEffort low / medium / high / max at the top level
|
||||||
|
*
|
||||||
|
* With DS_KEY or BACKTEST_MODEL_KEY, the same file can ask the provider which
|
||||||
|
* efforts it accepts. This checkout does not run that half.
|
||||||
|
*/
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { Agent } from "@mastra/core/agent";
|
||||||
|
import { createTool } from "@mastra/core/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { agentGenerationSettings } from "../src/lib/agent-generation-settings.ts";
|
||||||
|
import {
|
||||||
|
consultationContinueGenerationSettings,
|
||||||
|
consultationGenerationSettings,
|
||||||
|
consultationNatalPrepareStep,
|
||||||
|
consultationSliceGenerationSettings,
|
||||||
|
} from "../src/mastra/consultation-tools.ts";
|
||||||
|
|
||||||
|
const MODEL = {
|
||||||
|
providerId: "deepseek",
|
||||||
|
modelId: "deepseek-flash",
|
||||||
|
url: "http://127.0.0.1:9/v1",
|
||||||
|
apiKey: "dummy",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markTool = createTool({
|
||||||
|
id: "run-jyotish-consultation",
|
||||||
|
description: "Mark the calculation done.",
|
||||||
|
inputSchema: z.object({}).strict(),
|
||||||
|
execute: async () => ({ ok: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function sse(chunks: unknown[]) {
|
||||||
|
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallChunk() {
|
||||||
|
return sse([
|
||||||
|
{
|
||||||
|
id: "chatcmpl-tool",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: "call_1",
|
||||||
|
type: "function",
|
||||||
|
function: { name: "run-jyotish-consultation", arguments: "" },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-tool",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] },
|
||||||
|
finish_reason: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-tool",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textChunk() {
|
||||||
|
return sse([
|
||||||
|
{
|
||||||
|
id: "chatcmpl-text",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-text",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 1, completion_tokens: 1, completion_tokens_details: { reasoning_tokens: 0 } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestFields(body: unknown) {
|
||||||
|
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
||||||
|
return {
|
||||||
|
thinking: record.thinking ?? null,
|
||||||
|
reasoning_effort: record.reasoning_effort ?? null,
|
||||||
|
max_tokens: record.max_tokens ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureStreamBodies(input: {
|
||||||
|
settings: ReturnType<typeof consultationGenerationSettings>;
|
||||||
|
prepareStep?: (step: { stepNumber: number }) => { toolChoice?: "auto"; activeTools?: string[]; providerOptions?: unknown };
|
||||||
|
steps: 1 | 2;
|
||||||
|
}): Promise<unknown[]> {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
const previous = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const raw = init?.body;
|
||||||
|
bodies.push(JSON.parse(typeof raw === "string" ? raw : "{}"));
|
||||||
|
return bodies.length === 1 && input.steps === 2 ? toolCallChunk() : textChunk();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const agent = new Agent({
|
||||||
|
id: `answer-effort-probe-${bodies.length}-${Date.now()}`,
|
||||||
|
name: "answer-effort-probe",
|
||||||
|
instructions: "Reply ok.",
|
||||||
|
model: MODEL,
|
||||||
|
tools: { "run-jyotish-consultation": markTool },
|
||||||
|
});
|
||||||
|
const result = await agent.stream([{ role: "user", content: "ping" }], {
|
||||||
|
maxSteps: input.steps,
|
||||||
|
...input.settings,
|
||||||
|
...(input.prepareStep ? { prepareStep: input.prepareStep } : {}),
|
||||||
|
} as never);
|
||||||
|
const drained: unknown[] = [];
|
||||||
|
for await (const chunk of result.fullStream) drained.push(chunk);
|
||||||
|
if (drained.length === 0) throw new Error("probe stream produced no chunks");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previous;
|
||||||
|
}
|
||||||
|
return bodies;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.argv.includes("--dry-run")) {
|
||||||
|
console.log("A/B 未跑:缺模型 key。只做请求体拦截时加上 --dry-run。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = MODEL;
|
||||||
|
const natal = await captureStreamBodies({
|
||||||
|
settings: consultationGenerationSettings(model),
|
||||||
|
prepareStep: (input) => consultationNatalPrepareStep({
|
||||||
|
stepNumber: input.stepNumber,
|
||||||
|
answerReady: input.stepNumber >= 1,
|
||||||
|
}),
|
||||||
|
steps: 2,
|
||||||
|
});
|
||||||
|
const slice = await captureStreamBodies({
|
||||||
|
settings: consultationSliceGenerationSettings(model),
|
||||||
|
steps: 1,
|
||||||
|
});
|
||||||
|
const topLevel = [];
|
||||||
|
for (const effort of ["low", "medium", "high", "max"] as const) {
|
||||||
|
const bodies = await captureStreamBodies({
|
||||||
|
settings: agentGenerationSettings(model, { thinking: "enabled", providerReasoningEffort: effort }),
|
||||||
|
steps: 1,
|
||||||
|
});
|
||||||
|
topLevel.push({ effort, fields: requestFields(bodies[0]) });
|
||||||
|
}
|
||||||
|
const continued = await captureStreamBodies({
|
||||||
|
settings: consultationContinueGenerationSettings(model),
|
||||||
|
steps: 1,
|
||||||
|
});
|
||||||
|
const report = {
|
||||||
|
fixtureProvenance: "frontend/tests/answer-effort-probe.mts --dry-run",
|
||||||
|
a_consultationGenerationSettings: natal.map(requestFields),
|
||||||
|
b_consultationSliceGenerationSettings: slice.map(requestFields),
|
||||||
|
c_providerReasoningEffort: topLevel,
|
||||||
|
continue: continued.map(requestFields),
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
if (process.argv.includes("--save-baseline")) {
|
||||||
|
const file = new URL("./fixtures/answer-effort-switch-20261013.json", import.meta.url);
|
||||||
|
writeFileSync(file, `${JSON.stringify({
|
||||||
|
fixtureProvenance: "node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run --save-baseline(开关尚未读取环境变量时的整份请求体)",
|
||||||
|
natal: natal,
|
||||||
|
continue: continued,
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoked = process.argv[1]
|
||||||
|
? new URL(`file:///${process.argv[1].replace(/\\/g, "/")}`).href === import.meta.url
|
||||||
|
: false;
|
||||||
|
if (invoked) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Answer-step reasoning effort (issue #65).
|
||||||
|
* The unset bodies were captured before this switch read the environment:
|
||||||
|
* node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run --save-baseline
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
consultAnswerReasoningEffort,
|
||||||
|
consultationContinueGenerationSettings,
|
||||||
|
consultationGeneralPrepareStep,
|
||||||
|
consultationGenerationSettings,
|
||||||
|
consultationNatalPrepareStep,
|
||||||
|
} from "../src/mastra/consultation-tools.ts";
|
||||||
|
import { captureStreamBodies } from "./answer-effort-probe.mts";
|
||||||
|
|
||||||
|
const MODEL = {
|
||||||
|
providerId: "deepseek",
|
||||||
|
modelId: "deepseek-flash",
|
||||||
|
url: "http://127.0.0.1:9/v1",
|
||||||
|
apiKey: "dummy",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixture = JSON.parse(readFileSync(new URL("./fixtures/answer-effort-switch-20261013.json", import.meta.url), "utf8")) as {
|
||||||
|
natal: unknown[];
|
||||||
|
continue: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function withoutEffort(body: unknown) {
|
||||||
|
const copy = { ...(body as Record<string, unknown>) };
|
||||||
|
delete copy.reasoning_effort;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an illegal effort warns once and stays unset", () => {
|
||||||
|
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
const seen: unknown[][] = [];
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = (...args: unknown[]) => { seen.push(args); };
|
||||||
|
process.env.CONSULT_ANSWER_REASONING_EFFORT = "turbo";
|
||||||
|
try {
|
||||||
|
assert.equal(consultAnswerReasoningEffort(), undefined);
|
||||||
|
assert.equal(consultAnswerReasoningEffort(), undefined);
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unset effort keeps the pre-change request bodies", async () => {
|
||||||
|
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
try {
|
||||||
|
const natal = await captureStreamBodies({
|
||||||
|
settings: consultationGenerationSettings(MODEL),
|
||||||
|
prepareStep: (input) => consultationNatalPrepareStep({
|
||||||
|
stepNumber: input.stepNumber,
|
||||||
|
answerReady: input.stepNumber >= 1,
|
||||||
|
model: MODEL,
|
||||||
|
}),
|
||||||
|
steps: 2,
|
||||||
|
});
|
||||||
|
const continued = await captureStreamBodies({
|
||||||
|
settings: consultationContinueGenerationSettings(MODEL),
|
||||||
|
steps: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(natal, fixture.natal);
|
||||||
|
assert.deepEqual(continued, fixture.continue);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("low effort is only on the answer step", async () => {
|
||||||
|
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
process.env.CONSULT_ANSWER_REASONING_EFFORT = "low";
|
||||||
|
try {
|
||||||
|
const natal = await captureStreamBodies({
|
||||||
|
settings: consultationGenerationSettings(MODEL),
|
||||||
|
prepareStep: (input) => consultationNatalPrepareStep({
|
||||||
|
stepNumber: input.stepNumber,
|
||||||
|
answerReady: input.stepNumber >= 1,
|
||||||
|
model: MODEL,
|
||||||
|
}),
|
||||||
|
steps: 2,
|
||||||
|
}) as Record<string, unknown>[];
|
||||||
|
assert.equal(natal[0]?.reasoning_effort, undefined);
|
||||||
|
assert.deepEqual(natal[0], fixture.natal[0]);
|
||||||
|
assert.equal(natal[1]?.reasoning_effort, "low");
|
||||||
|
assert.deepEqual(withoutEffort(natal[1]), fixture.natal[1]);
|
||||||
|
const continued = await captureStreamBodies({
|
||||||
|
settings: consultationContinueGenerationSettings(MODEL),
|
||||||
|
steps: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(continued, fixture.continue);
|
||||||
|
const daily = await captureStreamBodies({
|
||||||
|
settings: consultationGenerationSettings(MODEL),
|
||||||
|
prepareStep: (input) => consultationGeneralPrepareStep({ stepNumber: input.stepNumber, model: MODEL }),
|
||||||
|
steps: 1,
|
||||||
|
}) as Record<string, unknown>[];
|
||||||
|
assert.equal(daily[0]?.reasoning_effort, "low");
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||||
|
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -90,6 +90,7 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses"
|
|||||||
assert.match(markup, /<dt>交点<\/dt>/);
|
assert.match(markup, /<dt>交点<\/dt>/);
|
||||||
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
|
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
|
||||||
assert.match(markup, /<caption>Bhava · Sripati<\/caption>/);
|
assert.match(markup, /<caption>Bhava · Sripati<\/caption>/);
|
||||||
|
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">换宫<\/th>/);
|
||||||
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
|
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
|
||||||
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
|
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
|
||||||
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/td>/g) ?? []).length, shifted.length);
|
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/td>/g) ?? []).length, shifted.length);
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a
|
|||||||
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
|
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
|
||||||
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
|
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
|
||||||
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
|
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
|
||||||
|
const table = markup.slice(markup.indexOf("月亮盘"));
|
||||||
|
const ascendantRow = table.match(/<th scope="row">[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
|
||||||
|
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
|
||||||
|
assert.match(ascendantRow, /is-sun/);
|
||||||
|
assert.match(ascendantRow, />1</);
|
||||||
|
assert.match(ascendantRow, />不适用</);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
|
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
|
||||||
@@ -125,7 +131,8 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode
|
|||||||
|
|
||||||
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
|
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
|
||||||
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
|
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
|
||||||
for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
|
for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
|
||||||
|
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">速度<\/th>/);
|
||||||
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
|
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
|
||||||
assert.match(markup, /<dl class="chart-page-params-table">/);
|
assert.match(markup, /<dl class="chart-page-params-table">/);
|
||||||
assert.match(markup, /<dt>岁差<\/dt>/);
|
assert.match(markup, /<dt>岁差<\/dt>/);
|
||||||
|
|||||||
@@ -48,7 +48,21 @@ test("the Vedic waiting slot reserves the result legend band without changing th
|
|||||||
|
|
||||||
test("only the house column in frozen natal and Moon tables overrides centering", () => {
|
test("only the house column in frozen natal and Moon tables overrides centering", () => {
|
||||||
assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
|
assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
|
||||||
|
assert.match(cssDeclarations(".chart-page-planet-table th.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
|
||||||
assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/);
|
assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/);
|
||||||
|
for (const child of [3, 6, 7]) {
|
||||||
|
assert.match(
|
||||||
|
cssDeclarations(`.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(${child})`, globalStyles),
|
||||||
|
/text-align:\s*center/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the current dasha card keeps its tint and drops the left accent line", () => {
|
||||||
|
const current = cssDeclarations(".chart-page-dasha-period.is-current", globalStyles);
|
||||||
|
assert.match(current, /background:\s*var\(--color-selected\)/);
|
||||||
|
assert.match(current, /border-color:\s*var\(--color-border-strong\)/);
|
||||||
|
assert.doesNotMatch(current, /box-shadow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wide chart-page content still scrolls inside its own box instead of widening the page", () => {
|
test("wide chart-page content still scrolls inside its own box instead of widening the page", () => {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
||||||
const roots = ["src/app/api", "src/lib"];
|
|
||||||
// Empty on purpose. A call site that cannot send skip must be listed here with a reason.
|
|
||||||
const allowlist: string[] = [];
|
|
||||||
|
|
||||||
function walk(dir: string): string[] {
|
|
||||||
const found: string[] = [];
|
|
||||||
for (const name of readdirSync(dir)) {
|
|
||||||
const full = path.join(dir, name);
|
|
||||||
if (statSync(full).isDirectory()) found.push(...walk(full));
|
|
||||||
else if (name.endsWith(".ts") || name.endsWith(".tsx")) found.push(full);
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("python /api/chart bodies from app/api and lib send skip", () => {
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const root of roots) {
|
|
||||||
for (const file of walk(path.join(frontendRoot, root))) {
|
|
||||||
const rel = path.relative(frontendRoot, file).replaceAll("\\", "/");
|
|
||||||
if (allowlist.includes(rel)) continue;
|
|
||||||
const text = readFileSync(file, "utf8");
|
|
||||||
const re = /["']\/api\/chart["']/g;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = re.exec(text))) {
|
|
||||||
const idx = match.index;
|
|
||||||
const before = text.slice(Math.max(0, idx - 120), idx);
|
|
||||||
if (!/postPython\s*\(|callEngine\s*\(|fetchEngine\s*\(|fetch\s*\(/.test(before)) continue;
|
|
||||||
const window = text.slice(Math.max(0, idx - 40), idx + 420);
|
|
||||||
const sendsSkip = window.includes("skip_vedastro_main_entry_overview: true")
|
|
||||||
|| window.includes("transitChartBody(")
|
|
||||||
|| (/["']\/api\/chart["']\s*,\s*payload/.test(window) && text.includes("skip_vedastro_main_entry_overview: true"));
|
|
||||||
if (!sendsSkip) failures.push(`${rel}:${text.slice(0, idx).split("\n").length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.deepEqual(failures, []);
|
|
||||||
});
|
|
||||||
@@ -99,8 +99,11 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc
|
|||||||
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
|
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
|
||||||
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
|
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
|
||||||
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
|
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
|
||||||
|
// 原值:上升行状态格 <td>—</td>
|
||||||
|
// 新值:<td>不适用</td>
|
||||||
|
// 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。
|
||||||
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
|
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
|
||||||
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>—</td>';
|
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>不适用</td>';
|
||||||
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
|
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
|
||||||
assert.equal(table, baseline.replace(dashes, filled));
|
assert.equal(table, baseline.replace(dashes, filled));
|
||||||
});
|
});
|
||||||
@@ -113,6 +116,8 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async (
|
|||||||
const ascendant = row(render(view, "D1"), "上升");
|
const ascendant = row(render(view, "D1"), "上升");
|
||||||
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
|
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
|
||||||
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
|
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
|
||||||
|
assert.match(ascendant, />1</, "pada 1");
|
||||||
|
assert.match(ascendant, />不适用</);
|
||||||
// An older cached chart without the fields still renders, with dashes.
|
// An older cached chart without the fields still renders, with dashes.
|
||||||
const chart = structuredClone(golden.chart);
|
const chart = structuredClone(golden.chart);
|
||||||
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
|
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
|
||||||
@@ -136,6 +141,8 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
|
|||||||
const markup = render(view, "D9");
|
const markup = render(view, "D9");
|
||||||
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
|
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
|
||||||
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
|
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
|
||||||
|
assert.match(header, /<th scope="col" class="chart-page-planet-mid">庙旺<\/th>/);
|
||||||
|
assert.match(header, /<th scope="col" class="chart-page-planet-mid">同位<\/th>/);
|
||||||
assert.doesNotMatch(header, /星宿|宿主|pada/);
|
assert.doesNotMatch(header, /星宿|宿主|pada/);
|
||||||
// Same wording as the D1 status column (BUG-1202), friendship levels included.
|
// Same wording as the D1 status column (BUG-1202), friendship levels included.
|
||||||
// 原值:同位行星单元格是「同位」,非同位是「—」
|
// 原值:同位行星单元格是「同位」,非同位是「—」
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ test("the consult route starts the general / no-birth-minute loop on the answer
|
|||||||
);
|
);
|
||||||
// That branch still streams on the shared loop signal and keeps its traces.
|
// That branch still streams on the shared loop signal and keeps its traces.
|
||||||
const general = route.match(/if \(usesPublicDailyGeneralAgent\(consultationMode, generalDailyContext\)\) \{[\s\S]*?\n {4}\}\n/)?.[0] ?? "";
|
const general = route.match(/if \(usesPublicDailyGeneralAgent\(consultationMode, generalDailyContext\)\) \{[\s\S]*?\n {4}\}\n/)?.[0] ?? "";
|
||||||
assert.match(general, /await streamWithOverflowRetry\(agent\)/);
|
// 原值:await streamWithOverflowRetry(agent)
|
||||||
|
// 新值:await streamWithOverflowRetry(agent, generalStreamOptions)
|
||||||
|
// 原因:公开日历从第一步就是答题。开关打开时这一步要带推理强度;不传选项就没有 prepareStep,强度加不上去。开关不设时这些选项不改请求体。
|
||||||
|
assert.match(general, /await streamWithOverflowRetry\(agent, generalStreamOptions\)/);
|
||||||
assert.match(general, /onError: \(error\) => settleRun\(\s*cancel,/);
|
assert.match(general, /onError: \(error\) => settleRun\(\s*cancel,/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"fixtureProvenance": "node node_modules/tsx/dist/cli.mjs scripts/research/answer-effort-probe.mts --dry-run --save-baseline(开关尚未读取环境变量时的整份请求体)",
|
||||||
|
"natal": [
|
||||||
|
{
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"max_tokens": 24576,
|
||||||
|
"thinking": {
|
||||||
|
"type": "enabled"
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "Reply ok."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "ping"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run-jyotish-consultation",
|
||||||
|
"description": "Mark the calculation done.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"stream": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"max_tokens": 24576,
|
||||||
|
"thinking": {
|
||||||
|
"type": "enabled"
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "Reply ok."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "ping"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run-jyotish-consultation",
|
||||||
|
"arguments": "{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_1",
|
||||||
|
"content": "{\"ok\":true}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run-jyotish-consultation",
|
||||||
|
"description": "Mark the calculation done.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"continue": [
|
||||||
|
{
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"max_tokens": 16384,
|
||||||
|
"thinking": {
|
||||||
|
"type": "disabled"
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "Reply ok."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "ping"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run-jyotish-consultation",
|
||||||
|
"description": "Mark the calculation done.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).",
|
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1202): varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions. 2026-10-15 (BUG-1352): chart.ascendant nakshatra, nakshatra_pada and nakshatra_lord are the fields returned by JyotishAPIHandler._compute_chart_sync for this birth (tests/test_api_chart_ascendant_nakshatra.py); they are not filled in by hand.",
|
||||||
"chart": {
|
"chart": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"birth": {
|
"birth": {
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
||||||
const partnerId = "11111111-1111-4111-8111-111111111111";
|
|
||||||
|
|
||||||
function runRoute(script: string): { romance: Record<string, unknown>; business: Record<string, unknown>; maxChart: number; maxVarga: number; chartSkip: boolean } {
|
|
||||||
const result = spawnSync(process.execPath, [
|
|
||||||
"--experimental-test-module-mocks",
|
|
||||||
"--import",
|
|
||||||
"tsx",
|
|
||||||
"--input-type=module",
|
|
||||||
"--eval",
|
|
||||||
script,
|
|
||||||
], { cwd: frontendRoot, encoding: "utf8", timeout: 20000 });
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
||||||
const line = result.stdout.trim().split("\n").at(-1) ?? "{}";
|
|
||||||
return JSON.parse(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("synastry charts send skip concurrently and keep the stubbed JSON", () => {
|
|
||||||
const script = String.raw`
|
|
||||||
import { mock } from "node:test";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
|
|
||||||
mock.module("server-only", { namedExports: {} });
|
|
||||||
mock.module("@/lib/account-deletion-server", { namedExports: { refuseWhenAccountDeletionPending: async () => null } });
|
|
||||||
mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => true } });
|
|
||||||
mock.module("@/lib/request-rate-limit", { namedExports: { consumeUserRequestRateLimit: () => ({ ok: true, retryAfterSeconds: 1 }) } });
|
|
||||||
mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) } }) } });
|
|
||||||
mock.module("@/lib/subject-birth", { namedExports: {
|
|
||||||
ConsultationSubjectError: class ConsultationSubjectError extends Error {},
|
|
||||||
loadSubjectBirth: async () => ({ chartable: true, row: { name: "fixture" } }),
|
|
||||||
} });
|
|
||||||
mock.module("@/lib/server-owned-birth-profile", { namedExports: { globalBirthProfileFromAccountRow: (row) => row } });
|
|
||||||
mock.module("@/lib/global-birth-payloads", { namedExports: {
|
|
||||||
synastryBirthPayload: async () => ({ year: 1990, month: 1, day: 1, hour: 12, minute: 0, lat: 28.6139, lon: 77.209, tz: 5.5, ayanamsa: "lahiri" }),
|
|
||||||
} });
|
|
||||||
const chartBodies = [];
|
|
||||||
const maxInflight = { chart: 0, varga: 0 };
|
|
||||||
const inflight = { chart: 0, varga: 0 };
|
|
||||||
const waiters = { chart: [], varga: [] };
|
|
||||||
globalThis.fetch = async (inputUrl, init) => {
|
|
||||||
const url = String(inputUrl);
|
|
||||||
const body = JSON.parse(String(init.body));
|
|
||||||
const kind = url.endsWith("/api/chart") ? "chart" : url.endsWith("/api/varga_full") ? "varga" : "other";
|
|
||||||
if (kind === "chart") chartBodies.push(body);
|
|
||||||
if (kind === "chart" || kind === "varga") {
|
|
||||||
inflight[kind] += 1;
|
|
||||||
maxInflight[kind] = Math.max(maxInflight[kind], inflight[kind]);
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
waiters[kind].push(resolve);
|
|
||||||
if (waiters[kind].length >= 2) {
|
|
||||||
const pending = waiters[kind].splice(0, 2);
|
|
||||||
pending.forEach((fn) => fn());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
inflight[kind] -= 1;
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/chart")) {
|
|
||||||
return Response.json({
|
|
||||||
planets: { Moon: { lon: 12.5, sign: "Aries", nakshatra: "Ashwini", nakshatra_pada: 1, nakshatra_lord: "Ketu" } },
|
|
||||||
ascendant: { sign: "Leo" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/varga_full")) {
|
|
||||||
const divisions = body.divisions || [];
|
|
||||||
if (divisions.includes("D9")) {
|
|
||||||
return Response.json({ source: "engine", result: { D9_Navamsa: { ascendant: { sign: "Virgo" }, planets: { Moon: { sign: "Cancer" }, Venus: { sign: "Taurus" }, Mars: { sign: "Aries" } } } } });
|
|
||||||
}
|
|
||||||
return Response.json({ source: "engine", result: {
|
|
||||||
D2_Hora: { planets: { Moon: { sign: "Taurus" } } },
|
|
||||||
D10_Dasamsa: { ascendant: { sign: "Libra" } },
|
|
||||||
D11_Rudramsa: { planets: { Sun: { sign: "Capricorn" } } },
|
|
||||||
} });
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/synastry")) return Response.json({ total_score: 18, max_score: 36 });
|
|
||||||
return Response.json({}, { status: 500 });
|
|
||||||
};
|
|
||||||
const { POST } = await import(moduleUrl("src/app/api/synastry/route.ts"));
|
|
||||||
const romanceRes = await POST(new Request("https://example.test/api/synastry", { method: "POST", body: JSON.stringify({ partnerChartProfileId: "${partnerId}", relationshipType: "romance" }) }));
|
|
||||||
const romance = await romanceRes.json();
|
|
||||||
const businessRes = await POST(new Request("https://example.test/api/synastry", { method: "POST", body: JSON.stringify({ partnerChartProfileId: "${partnerId}", relationshipType: "business" }) }));
|
|
||||||
const business = await businessRes.json();
|
|
||||||
console.log(JSON.stringify({ romance, business, maxChart: maxInflight.chart, maxVarga: maxInflight.varga, chartSkip: chartBodies.every((body) => body.skip_vedastro_main_entry_overview === true) && chartBodies.length === 4 }));
|
|
||||||
`;
|
|
||||||
const got = runRoute(script);
|
|
||||||
assert.equal(got.maxChart, 2);
|
|
||||||
assert.equal(got.maxVarga, 2);
|
|
||||||
assert.equal(got.chartSkip, true);
|
|
||||||
assert.equal(got.romance.status, "ok");
|
|
||||||
assert.equal(got.romance.method, "ashtakoot_plus_moon_nakshatra_d9");
|
|
||||||
assert.deepEqual(got.romance.selfChart, {
|
|
||||||
moon: { sign: "Aries", nakshatra: "Ashwini", pada: 1, lord: "Ketu", longitude: 12.5 },
|
|
||||||
d9: { ascendant: { sign: "Virgo" }, moon: { sign: "Cancer" }, venus: { sign: "Taurus" }, mars: { sign: "Aries" }, source: "engine" },
|
|
||||||
});
|
|
||||||
assert.equal((got.romance.relationshipReport as { scoreBand?: string }).scoreBand, "mixed");
|
|
||||||
assert.equal(got.business.status, "ok");
|
|
||||||
assert.equal(got.business.method, "d2_d10_d11_business_screening_partial");
|
|
||||||
assert.equal((got.business.relationshipReport as { scoreBand?: string }).scoreBand, "not_scored");
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -23,34 +22,6 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
ENGINE = "PyJHora/JHora"
|
ENGINE = "PyJHora/JHora"
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
_jhora_spec_cache: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def reset_jhora_import_cache() -> None:
|
|
||||||
"""Tests reset the process-local "is PyJHora installed?" answer."""
|
|
||||||
global _jhora_spec_cache
|
|
||||||
_jhora_spec_cache = None
|
|
||||||
|
|
||||||
|
|
||||||
def _jhora_importable() -> bool:
|
|
||||||
global _jhora_spec_cache
|
|
||||||
if _jhora_spec_cache is None:
|
|
||||||
_jhora_spec_cache = importlib.util.find_spec("jhora") is not None
|
|
||||||
return _jhora_spec_cache
|
|
||||||
|
|
||||||
|
|
||||||
def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Same dict the child returns when ``import jhora`` raises ModuleNotFoundError."""
|
|
||||||
copied = dict(settings)
|
|
||||||
return {
|
|
||||||
"status": "blocked",
|
|
||||||
"reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError",
|
|
||||||
"settings": copied,
|
|
||||||
"node_mode": {
|
|
||||||
"requested": copied.get("node_mode"),
|
|
||||||
"status": "unsupported_by_replay_adapter",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
|
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
|
||||||
_YEAR_LORD_MODULES = (
|
_YEAR_LORD_MODULES = (
|
||||||
"jhora.horoscope.dhasa.annual.mudda",
|
"jhora.horoscope.dhasa.annual.mudda",
|
||||||
@@ -168,10 +139,6 @@ def _run_isolated_tajaka_year_lord(
|
|||||||
"settings": settings,
|
"settings": settings,
|
||||||
}
|
}
|
||||||
request_hash = _request_hash(request)
|
request_hash = _request_hash(request)
|
||||||
if not _jhora_importable():
|
|
||||||
replay = _no_jhora_tajaka_replay(settings)
|
|
||||||
replay["request_hash"] = request_hash
|
|
||||||
return replay
|
|
||||||
command = [
|
command = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-c",
|
"-c",
|
||||||
@@ -309,10 +276,6 @@ def _run_isolated_annual_replay(
|
|||||||
"settings": settings,
|
"settings": settings,
|
||||||
}
|
}
|
||||||
request_hash = _request_hash(request)
|
request_hash = _request_hash(request)
|
||||||
if not _jhora_importable():
|
|
||||||
replay = _no_jhora_annual_replay(settings)
|
|
||||||
replay["request_hash"] = request_hash
|
|
||||||
return replay
|
|
||||||
command = [
|
command = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-c",
|
"-c",
|
||||||
@@ -428,18 +391,6 @@ def _annual_worker_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _no_jhora_annual_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Same dict the annual child returns when ``import jhora`` raises ModuleNotFoundError."""
|
|
||||||
copied = dict(settings)
|
|
||||||
node_mode = {"requested": copied.get("node_mode"), "status": "unsupported_by_replay_adapter"}
|
|
||||||
reason = "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError"
|
|
||||||
return _annual_worker_result(
|
|
||||||
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
|
||||||
settings=copied,
|
|
||||||
node_mode=node_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||||
return _annual_worker_result(
|
return _annual_worker_result(
|
||||||
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
||||||
|
|||||||
@@ -8,17 +8,13 @@ must not call this gate.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
|
|
||||||
DEFAULT_CONCURRENCY = 2
|
DEFAULT_CONCURRENCY = 2
|
||||||
DEFAULT_RETRY_AFTER_SECONDS = 2
|
DEFAULT_RETRY_AFTER_SECONDS = 2
|
||||||
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
|
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
|
||||||
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
|
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
|
||||||
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
|
|
||||||
_LOG = logging.getLogger("jyotish.heavy_compute")
|
|
||||||
|
|
||||||
HEAVY_COMPUTE_PATHS = frozenset(
|
HEAVY_COMPUTE_PATHS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -50,26 +46,6 @@ HEAVY_COMPUTE_PATHS = frozenset(
|
|||||||
_state_lock = threading.Lock()
|
_state_lock = threading.Lock()
|
||||||
_semaphore: threading.BoundedSemaphore | None = None
|
_semaphore: threading.BoundedSemaphore | None = None
|
||||||
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
|
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
|
||||||
_limit = DEFAULT_CONCURRENCY
|
|
||||||
_holders: list[dict] = []
|
|
||||||
_holder_seq = 0
|
|
||||||
|
|
||||||
|
|
||||||
class _HeldSlot:
|
|
||||||
"""One acquired heavy-compute permit, paired with the path that holds it."""
|
|
||||||
|
|
||||||
def __init__(self, semaphore: threading.BoundedSemaphore, holder_id: int) -> None:
|
|
||||||
self._semaphore = semaphore
|
|
||||||
self._holder_id = holder_id
|
|
||||||
self._released = False
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
with _state_lock:
|
|
||||||
if self._released:
|
|
||||||
return
|
|
||||||
self._released = True
|
|
||||||
_holders[:] = [item for item in _holders if item["id"] != self._holder_id]
|
|
||||||
self._semaphore.release()
|
|
||||||
|
|
||||||
|
|
||||||
class HeavyComputeBusy(RuntimeError):
|
class HeavyComputeBusy(RuntimeError):
|
||||||
@@ -104,7 +80,7 @@ def reset_heavy_compute_gate(
|
|||||||
retry_after_seconds: int | None = None,
|
retry_after_seconds: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
|
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
|
||||||
global _semaphore, _retry_after_seconds, _limit, _holders
|
global _semaphore, _retry_after_seconds
|
||||||
resolved_limit = (
|
resolved_limit = (
|
||||||
limit
|
limit
|
||||||
if limit is not None
|
if limit is not None
|
||||||
@@ -117,96 +93,31 @@ def reset_heavy_compute_gate(
|
|||||||
)
|
)
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_retry_after_seconds = resolved_retry
|
_retry_after_seconds = resolved_retry
|
||||||
_limit = resolved_limit
|
|
||||||
_holders = []
|
|
||||||
_semaphore = threading.BoundedSemaphore(resolved_limit)
|
_semaphore = threading.BoundedSemaphore(resolved_limit)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]:
|
def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]:
|
||||||
global _semaphore, _retry_after_seconds, _limit
|
global _semaphore, _retry_after_seconds
|
||||||
if _semaphore is None:
|
if _semaphore is None:
|
||||||
_limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
||||||
_retry_after_seconds = _parse_positive_int(
|
_retry_after_seconds = _parse_positive_int(
|
||||||
os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS
|
os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS
|
||||||
)
|
)
|
||||||
_semaphore = threading.BoundedSemaphore(_limit)
|
_semaphore = threading.BoundedSemaphore(limit)
|
||||||
return _semaphore, _retry_after_seconds
|
return _semaphore, _retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
def _holder_view(now: float) -> list[dict]:
|
def acquire_heavy_compute_slot(path: str) -> threading.BoundedSemaphore | None:
|
||||||
return [
|
|
||||||
{
|
|
||||||
"path": item["path"],
|
|
||||||
"held_seconds": round(max(now - item["started"], 0.0), 3),
|
|
||||||
}
|
|
||||||
for item in _holders
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _acquire_recorded(path: str) -> _HeldSlot:
|
|
||||||
"""Take one permit and remember which path holds it. Fail fast when full."""
|
|
||||||
global _holder_seq
|
|
||||||
with _state_lock:
|
|
||||||
semaphore, retry_after = _ensure_locked()
|
|
||||||
if not semaphore.acquire(blocking=False):
|
|
||||||
snapshot = _holder_view(time.monotonic())
|
|
||||||
failed = True
|
|
||||||
slot = None
|
|
||||||
else:
|
|
||||||
_holder_seq += 1
|
|
||||||
holder_id = _holder_seq
|
|
||||||
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
|
|
||||||
failed = False
|
|
||||||
snapshot = []
|
|
||||||
slot = _HeldSlot(semaphore, holder_id)
|
|
||||||
if failed:
|
|
||||||
_LOG.warning(
|
|
||||||
"heavy_compute_busy rejected_path=%s holders=%s",
|
|
||||||
path,
|
|
||||||
snapshot,
|
|
||||||
)
|
|
||||||
raise HeavyComputeBusy(retry_after)
|
|
||||||
assert slot is not None
|
|
||||||
return slot
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
|
|
||||||
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
|
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
|
||||||
if not is_heavy_compute_path(path):
|
if not is_heavy_compute_path(path):
|
||||||
return None
|
return None
|
||||||
return _acquire_recorded(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _chart_overview_gate_enabled() -> bool:
|
|
||||||
return os.environ.get(ENV_GATE_CHART_OVERVIEW, "").strip() == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_heavy_compute_slot_for_request(path: str, body: dict | None) -> _HeldSlot | None:
|
|
||||||
"""Path gate, plus the chart-overview switch and the warm-consultation exception.
|
|
||||||
|
|
||||||
``/api/chart`` stays out of ``HEAVY_COMPUTE_PATHS``. It takes a slot only when
|
|
||||||
``JYOTISH_GATE_CHART_OVERVIEW=1``, the body does not skip the VedAstro overview,
|
|
||||||
and the body is not an async enqueue. Default is off.
|
|
||||||
"""
|
|
||||||
payload = body if isinstance(body, dict) else {}
|
|
||||||
if path == "/api/consultation_workflow" and payload.get("consult_card_warm_only") is True:
|
|
||||||
return None
|
|
||||||
if path == "/api/chart" and _chart_overview_gate_enabled():
|
|
||||||
if payload.get("async") or payload.get("enqueue"):
|
|
||||||
return None
|
|
||||||
if payload.get("skip_vedastro_main_entry_overview"):
|
|
||||||
return None
|
|
||||||
return _acquire_recorded(path)
|
|
||||||
return acquire_heavy_compute_slot(path)
|
|
||||||
|
|
||||||
|
|
||||||
def heavy_compute_status() -> dict:
|
|
||||||
"""Process-local counts only. No request body and no user identity."""
|
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_ensure_locked()
|
semaphore, retry_after = _ensure_locked()
|
||||||
return {"limit": _limit, "in_use": len(_holders)}
|
if not semaphore.acquire(blocking=False):
|
||||||
|
raise HeavyComputeBusy(retry_after)
|
||||||
|
return semaphore
|
||||||
|
|
||||||
|
|
||||||
def release_heavy_compute_slot(slot: _HeldSlot | None) -> None:
|
def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None:
|
||||||
if slot is not None:
|
if slot is not None:
|
||||||
slot.release()
|
slot.release()
|
||||||
|
|||||||
@@ -122,13 +122,13 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
|
|||||||
try:
|
try:
|
||||||
from scripts.api_heavy_compute_gate import (
|
from scripts.api_heavy_compute_gate import (
|
||||||
HeavyComputeBusy,
|
HeavyComputeBusy,
|
||||||
acquire_heavy_compute_slot_for_request,
|
acquire_heavy_compute_slot,
|
||||||
release_heavy_compute_slot,
|
release_heavy_compute_slot,
|
||||||
)
|
)
|
||||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||||
from api_heavy_compute_gate import (
|
from api_heavy_compute_gate import (
|
||||||
HeavyComputeBusy,
|
HeavyComputeBusy,
|
||||||
acquire_heavy_compute_slot_for_request,
|
acquire_heavy_compute_slot,
|
||||||
release_heavy_compute_slot,
|
release_heavy_compute_slot,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2589,12 +2589,24 @@ def _vedastro_runtime_fingerprint() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_ascendant_nakshatra(ascendant_data: dict, lon: float) -> dict:
|
||||||
|
"""Keep the engine's nakshatra fields when /api/chart rebuilds the ascendant."""
|
||||||
|
fields = {
|
||||||
|
key: ascendant_data[key]
|
||||||
|
for key in ('nakshatra', 'nakshatra_pada', 'nakshatra_lord')
|
||||||
|
if ascendant_data.get(key) not in (None, '')
|
||||||
|
}
|
||||||
|
if 'nakshatra' in fields:
|
||||||
|
return fields
|
||||||
|
return _load_local_module('jyotish_engine')._nakshatra_fields(lon)
|
||||||
|
|
||||||
|
|
||||||
def _build_api_chart_cache_payload(body: dict) -> dict:
|
def _build_api_chart_cache_payload(body: dict) -> dict:
|
||||||
import api_scratch_retention as retention
|
import api_scratch_retention as retention
|
||||||
|
|
||||||
today = body.get('today') or body.get('current_date')
|
today = body.get('today') or body.get('current_date')
|
||||||
return {
|
return {
|
||||||
'cache_schema_version': 6,
|
'cache_schema_version': 7,
|
||||||
'birth': retention.normalize_chart_cache_birth(body),
|
'birth': retention.normalize_chart_cache_birth(body),
|
||||||
'calculation': {
|
'calculation': {
|
||||||
'ayanamsa': _request_ayanamsa(body),
|
'ayanamsa': _request_ayanamsa(body),
|
||||||
@@ -3345,10 +3357,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
|||||||
try:
|
try:
|
||||||
self._enforce_request_security(require_json=True)
|
self._enforce_request_security(require_json=True)
|
||||||
body = self._read_json_body()
|
body = self._read_json_body()
|
||||||
# Warm chart-save must not take a slot (BUG-1302). Chart overview
|
# Chart-save warm builds today's packet on the same route. It must
|
||||||
# gating is off unless JYOTISH_GATE_CHART_OVERVIEW=1. Both live in
|
# not take a heavy-compute slot: the cap is 2, and a question asked
|
||||||
# the gate module so this file only keeps the call.
|
# right after save would otherwise come back 429.
|
||||||
compute_slot = acquire_heavy_compute_slot_for_request(path, body)
|
warm_only = (
|
||||||
|
path == '/api/consultation_workflow'
|
||||||
|
and body.get('consult_card_warm_only') is True
|
||||||
|
)
|
||||||
|
compute_slot = None if warm_only else acquire_heavy_compute_slot(path)
|
||||||
if path == '/api/location/resolve':
|
if path == '/api/location/resolve':
|
||||||
city = str(body.get('city') or '').strip()
|
city = str(body.get('city') or '').strip()
|
||||||
city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'}
|
city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'}
|
||||||
@@ -6659,6 +6675,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
|||||||
'degree': round(asc_lon % 30, 2),
|
'degree': round(asc_lon % 30, 2),
|
||||||
'degree_in_sign': round(asc_lon % 30, 2),
|
'degree_in_sign': round(asc_lon % 30, 2),
|
||||||
'lon': round(asc_lon, 4),
|
'lon': round(asc_lon, 4),
|
||||||
|
**_chart_ascendant_nakshatra(ascendant_data, asc_lon),
|
||||||
},
|
},
|
||||||
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
|
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
|
||||||
'dasha': {
|
'dasha': {
|
||||||
|
|||||||
@@ -10762,25 +10762,7 @@ def _planet_snapshot(planets, planet_name):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_oracle_progress_cache: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
def reset_oracle_progress_snapshot_cache():
|
|
||||||
"""Tests clear the static-file validation cache."""
|
|
||||||
_oracle_progress_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _oracle_progress_cache_key(oracle_file):
|
|
||||||
try:
|
|
||||||
stat = oracle_file.stat()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
mtime = getattr(stat, 'st_mtime_ns', stat.st_mtime)
|
|
||||||
return (str(oracle_file.resolve()), mtime, stat.st_size)
|
|
||||||
|
|
||||||
|
|
||||||
def _oracle_progress_snapshot():
|
def _oracle_progress_snapshot():
|
||||||
cache_key = None
|
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -10790,9 +10772,6 @@ def _oracle_progress_snapshot():
|
|||||||
|
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
oracle_file = root / 'references' / 'oracle' / 'dasha_shadbala_oracle_cases.json'
|
oracle_file = root / 'references' / 'oracle' / 'dasha_shadbala_oracle_cases.json'
|
||||||
cache_key = _oracle_progress_cache_key(oracle_file)
|
|
||||||
if cache_key is not None and cache_key in _oracle_progress_cache:
|
|
||||||
return _oracle_progress_cache[cache_key]
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
queue_path = Path(temp_dir) / 'oracle-queue.json'
|
queue_path = Path(temp_dir) / 'oracle-queue.json'
|
||||||
queue = subprocess.run(
|
queue = subprocess.run(
|
||||||
@@ -10818,7 +10797,7 @@ def _oracle_progress_snapshot():
|
|||||||
raise RuntimeError(validation.stderr.strip() or validation.stdout.strip())
|
raise RuntimeError(validation.stderr.strip() or validation.stdout.strip())
|
||||||
report = json.loads(validation.stdout)
|
report = json.loads(validation.stdout)
|
||||||
summary = report.get('summary', {})
|
summary = report.get('summary', {})
|
||||||
result = {
|
return {
|
||||||
'scope': 'external_oracle_evidence_validation',
|
'scope': 'external_oracle_evidence_validation',
|
||||||
'collection_queue': 'external_oracle_collection_queue',
|
'collection_queue': 'external_oracle_collection_queue',
|
||||||
'total_packets': summary.get('total_packets', 0),
|
'total_packets': summary.get('total_packets', 0),
|
||||||
@@ -10830,7 +10809,7 @@ def _oracle_progress_snapshot():
|
|||||||
'boundary': report.get('boundary', 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.'),
|
'boundary': report.get('boundary', 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.'),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result = {
|
return {
|
||||||
'scope': 'external_oracle_evidence_validation',
|
'scope': 'external_oracle_evidence_validation',
|
||||||
'collection_queue': 'external_oracle_collection_queue',
|
'collection_queue': 'external_oracle_collection_queue',
|
||||||
'total_packets': 0,
|
'total_packets': 0,
|
||||||
@@ -10841,9 +10820,6 @@ def _oracle_progress_snapshot():
|
|||||||
'promotion_rule': 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.',
|
'promotion_rule': 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.',
|
||||||
'boundary': f'Oracle progress unavailable: {exc}',
|
'boundary': f'Oracle progress unavailable: {exc}',
|
||||||
}
|
}
|
||||||
if cache_key is not None:
|
|
||||||
_oracle_progress_cache[cache_key] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# Combustion orbs of the full reading (Step 5 shadbala block). The Moon, Rahu
|
# Combustion orbs of the full reading (Step 5 shadbala block). The Moon, Rahu
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
"""A/B for issue #64. A is this branch with the PyJHora short-circuit forced off
|
|
||||||
(the staging control flow: always spawn). B is the short-circuit on.
|
|
||||||
|
|
||||||
Chart skip/concurrency is measured on the real chart call. The gate schemes
|
|
||||||
O2/O3/O4 are simulated only and are not implemented.
|
|
||||||
|
|
||||||
Volatile fields dropped before the report byte compare:
|
|
||||||
generated_at, created_at, timestamp, elapsed_seconds, elapsed_ms, duration_ms, request_id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import statistics
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
sys.path.insert(0, str(ROOT / "scripts"))
|
|
||||||
|
|
||||||
os.environ.setdefault("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
|
||||||
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
|
|
||||||
os.environ.setdefault("JYOTISH_GATE_CHART_OVERVIEW", "0")
|
|
||||||
|
|
||||||
import annual_pyjhora_replay as replay # noqa: E402
|
|
||||||
import tajika as tajika_top # noqa: E402
|
|
||||||
from scripts.jyotish_api_server import JyotishAPIHandler # noqa: E402
|
|
||||||
from scripts.jyotish_engine import reset_oracle_progress_snapshot_cache # noqa: E402
|
|
||||||
from scripts.professional_report_reference import build_professional_report_reference # noqa: E402
|
|
||||||
from scripts.tajika import reset_external_pyjhora_python_cache # noqa: E402
|
|
||||||
|
|
||||||
BIRTH = {
|
|
||||||
"year": 1990,
|
|
||||||
"month": 1,
|
|
||||||
"day": 1,
|
|
||||||
"hour": 12,
|
|
||||||
"minute": 0,
|
|
||||||
"second": 0,
|
|
||||||
"lat": 28.6139,
|
|
||||||
"lon": 77.2090,
|
|
||||||
"tz": 5.5,
|
|
||||||
"ayanamsa": "lahiri",
|
|
||||||
"node_mode": "true",
|
|
||||||
}
|
|
||||||
PARTNER = {**BIRTH, "year": 1992, "day": 2}
|
|
||||||
VOLATILE = {"generated_at", "created_at", "timestamp", "elapsed_seconds", "elapsed_ms", "duration_ms", "request_id"}
|
|
||||||
|
|
||||||
|
|
||||||
class _Counter:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.n = 0
|
|
||||||
self._real = subprocess.run
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def install(self) -> None:
|
|
||||||
def _run(*args, **kwargs):
|
|
||||||
with self._lock:
|
|
||||||
self.n += 1
|
|
||||||
return self._real(*args, **kwargs)
|
|
||||||
|
|
||||||
subprocess.run = _run # type: ignore[assignment]
|
|
||||||
for module in list(sys.modules.values()):
|
|
||||||
bound = getattr(module, "subprocess", None)
|
|
||||||
if getattr(bound, "run", None) is self._real:
|
|
||||||
bound.run = _run # type: ignore[union-attr]
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self.n = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _median(values: list[float]) -> float:
|
|
||||||
return statistics.median(values) if values else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _strip(value):
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {key: _strip(item) for key, item in value.items() if key not in VOLATILE}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_strip(item) for item in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _canon(value) -> str:
|
|
||||||
return json.dumps(_strip(value), sort_keys=True, ensure_ascii=True, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _chart_summary(chart: dict) -> dict:
|
|
||||||
planets = chart.get("planets") if isinstance(chart, dict) else None
|
|
||||||
moon = planets.get("Moon") if isinstance(planets, dict) else None
|
|
||||||
lon = None
|
|
||||||
if isinstance(moon, dict):
|
|
||||||
lon = moon.get("lon", moon.get("longitude"))
|
|
||||||
return {
|
|
||||||
"ascendant": chart.get("ascendant") if isinstance(chart, dict) else None,
|
|
||||||
"planets": planets,
|
|
||||||
"moon_longitude": lon,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _one_chart(body: dict) -> tuple[float, dict]:
|
|
||||||
started = time.perf_counter()
|
|
||||||
chart = object.__new__(JyotishAPIHandler)._compute_chart_sync(body)
|
|
||||||
return time.perf_counter() - started, chart
|
|
||||||
|
|
||||||
|
|
||||||
def _measure_charts(counter: _Counter) -> dict:
|
|
||||||
serial_times = []
|
|
||||||
concurrent_times = []
|
|
||||||
serial_subs = []
|
|
||||||
concurrent_subs = []
|
|
||||||
same = True
|
|
||||||
for _ in range(3):
|
|
||||||
counter.reset()
|
|
||||||
started = time.perf_counter()
|
|
||||||
_, self_a = _one_chart(BIRTH)
|
|
||||||
_, partner_a = _one_chart(PARTNER)
|
|
||||||
serial_times.append(time.perf_counter() - started)
|
|
||||||
serial_subs.append(counter.n)
|
|
||||||
counter.reset()
|
|
||||||
started = time.perf_counter()
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
||||||
self_b = pool.submit(_one_chart, {**BIRTH, "skip_vedastro_main_entry_overview": True})
|
|
||||||
partner_b = pool.submit(_one_chart, {**PARTNER, "skip_vedastro_main_entry_overview": True})
|
|
||||||
_, self_chart = self_b.result()
|
|
||||||
_, partner_chart = partner_b.result()
|
|
||||||
concurrent_times.append(time.perf_counter() - started)
|
|
||||||
concurrent_subs.append(counter.n)
|
|
||||||
same = same and _canon(_chart_summary(self_a)) == _canon(_chart_summary(self_chart))
|
|
||||||
same = same and _canon(_chart_summary(partner_a)) == _canon(_chart_summary(partner_chart))
|
|
||||||
return {
|
|
||||||
"serial_seconds_median": round(_median(serial_times), 4),
|
|
||||||
"concurrent_skip_seconds_median": round(_median(concurrent_times), 4),
|
|
||||||
"serial_subprocesses_median": _median(serial_subs),
|
|
||||||
"concurrent_skip_subprocesses_median": _median(concurrent_subs),
|
|
||||||
"summary_bytes_match": same,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _measure_report(counter: _Counter, *, spawn: bool) -> dict:
|
|
||||||
times = []
|
|
||||||
subs = []
|
|
||||||
payload = None
|
|
||||||
body = {**BIRTH, "format": "markdown", "edition": "full_data", "languages": ["zh", "en"], "include_fact_tables": True}
|
|
||||||
for _ in range(3):
|
|
||||||
replay.reset_jhora_import_cache()
|
|
||||||
if spawn:
|
|
||||||
replay._jhora_spec_cache = True
|
|
||||||
reset_oracle_progress_snapshot_cache()
|
|
||||||
reset_external_pyjhora_python_cache()
|
|
||||||
tajika_top.reset_external_pyjhora_python_cache()
|
|
||||||
counter.reset()
|
|
||||||
started = time.perf_counter()
|
|
||||||
payload = build_professional_report_reference(
|
|
||||||
object.__new__(JyotishAPIHandler),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
times.append(time.perf_counter() - started)
|
|
||||||
subs.append(counter.n)
|
|
||||||
return {
|
|
||||||
"seconds_median": round(_median(times), 4),
|
|
||||||
"subprocesses_median": _median(subs),
|
|
||||||
"payload": payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _simulate() -> list[dict]:
|
|
||||||
"""One fixed minute. O2 adds a second pool and is the only scheme that touches BUG-717."""
|
|
||||||
rng = random.Random(64)
|
|
||||||
arrivals = [("report", index * 30.0, 13.0) for index in range(2)]
|
|
||||||
arrivals += [("consult", rng.random() * 60.0, 0.2) for _ in range(20)]
|
|
||||||
arrivals += [("score", rng.random() * 60.0, 8.0) for _ in range(6)]
|
|
||||||
arrivals.sort(key=lambda item: item[1])
|
|
||||||
|
|
||||||
def run(mode: str) -> dict:
|
|
||||||
heavy: list[tuple[float, str]] = []
|
|
||||||
light: list[float] = []
|
|
||||||
rejected = {"consult": 0, "report": 0, "score": 0}
|
|
||||||
accepted = {"consult": 0, "report": 0, "score": 0}
|
|
||||||
peak = 0
|
|
||||||
|
|
||||||
def occupy(at: float) -> tuple[int, int]:
|
|
||||||
running = [item for item in heavy if item[0] > at]
|
|
||||||
lights = [item for item in light if item > at]
|
|
||||||
return len(running), len(lights)
|
|
||||||
|
|
||||||
for kind, arrival, duration in arrivals:
|
|
||||||
heavy_n, light_n = occupy(arrival)
|
|
||||||
heavy = [item for item in heavy if item[0] > arrival]
|
|
||||||
light = [item for item in light if item > arrival]
|
|
||||||
placed = False
|
|
||||||
stamp = arrival
|
|
||||||
if mode == "O2" and kind == "consult":
|
|
||||||
if light_n < 2:
|
|
||||||
light.append(arrival + duration)
|
|
||||||
placed = True
|
|
||||||
elif mode == "O3" and kind == "consult":
|
|
||||||
start = arrival
|
|
||||||
while start <= arrival + 1.5:
|
|
||||||
heavy_n, _light_n = occupy(start)
|
|
||||||
if heavy_n < 2:
|
|
||||||
heavy.append((start + duration, kind))
|
|
||||||
placed = True
|
|
||||||
stamp = start
|
|
||||||
break
|
|
||||||
nxt = min(item[0] for item in heavy)
|
|
||||||
if nxt > arrival + 1.5:
|
|
||||||
break
|
|
||||||
start = nxt
|
|
||||||
elif mode == "O4" and kind != "consult":
|
|
||||||
heavy_jobs = sum(1 for _end, slot in heavy if slot != "consult")
|
|
||||||
if heavy_jobs < 1 and heavy_n < 2:
|
|
||||||
heavy.append((arrival + duration, kind))
|
|
||||||
placed = True
|
|
||||||
else:
|
|
||||||
if heavy_n < 2:
|
|
||||||
heavy.append((arrival + duration, kind))
|
|
||||||
placed = True
|
|
||||||
if placed:
|
|
||||||
accepted[kind] += 1
|
|
||||||
else:
|
|
||||||
rejected[kind] += 1
|
|
||||||
running, lights = occupy(stamp)
|
|
||||||
peak = max(peak, running + lights)
|
|
||||||
|
|
||||||
def rate(kind: str) -> str:
|
|
||||||
total = accepted[kind] + rejected[kind]
|
|
||||||
return "0" if total == 0 else f"{rejected[kind] / total:.2f}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scheme": mode,
|
|
||||||
"consult_429": rate("consult"),
|
|
||||||
"report_429": rate("report"),
|
|
||||||
"score_429": rate("score"),
|
|
||||||
"peak": peak,
|
|
||||||
"touches_bug_717": mode == "O2",
|
|
||||||
}
|
|
||||||
|
|
||||||
return [run(mode) for mode in ("O1", "O2", "O3", "O4")]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_record_path(explicit: str | None) -> Path:
|
|
||||||
"""Write the A/B JSON only where asked. The committed note stays untouched."""
|
|
||||||
if explicit:
|
|
||||||
return Path(explicit)
|
|
||||||
directory = Path(tempfile.mkdtemp(prefix="api-chart-subprocess-ab-"))
|
|
||||||
return directory / "api-chart-subprocess-gate-ab-20261013.json"
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="A/B for issue #64 chart subprocess gate")
|
|
||||||
parser.add_argument(
|
|
||||||
"--out",
|
|
||||||
default=None,
|
|
||||||
help="Write the JSON record to this path. Omit to use a fresh temp directory, not the committed docs/testing file.",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
counter = _Counter()
|
|
||||||
counter.install()
|
|
||||||
charts = _measure_charts(counter)
|
|
||||||
forced = _measure_report(counter, spawn=True)
|
|
||||||
native = _measure_report(counter, spawn=False)
|
|
||||||
report_match = _canon(forced["payload"]) == _canon(native["payload"])
|
|
||||||
schemes = _simulate()
|
|
||||||
out = {
|
|
||||||
"charts": charts,
|
|
||||||
"report_forced_subprocess": {key: value for key, value in forced.items() if key != "payload"},
|
|
||||||
"report_short_circuit": {key: value for key, value in native.items() if key != "payload"},
|
|
||||||
"report_bytes_match": report_match,
|
|
||||||
"schemes": schemes,
|
|
||||||
"jhora_installed": False,
|
|
||||||
}
|
|
||||||
text = json.dumps(out, ensure_ascii=False, indent=2)
|
|
||||||
dest = resolve_record_path(args.out)
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest.write_text(text + "\n", encoding="utf-8")
|
|
||||||
print(dest, file=sys.stderr)
|
|
||||||
print(text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1501,28 +1501,7 @@ print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_external_pyjhora_python_cached = False
|
|
||||||
_external_pyjhora_python_value: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def reset_external_pyjhora_python_cache() -> None:
|
|
||||||
"""Tests and a fresh process both start with no remembered interpreter."""
|
|
||||||
global _external_pyjhora_python_cached, _external_pyjhora_python_value
|
|
||||||
_external_pyjhora_python_cached = False
|
|
||||||
_external_pyjhora_python_value = None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_external_pyjhora_python() -> Optional[str]:
|
def _resolve_external_pyjhora_python() -> Optional[str]:
|
||||||
"""Remember the lookup, including "not installed", for this process."""
|
|
||||||
global _external_pyjhora_python_cached, _external_pyjhora_python_value
|
|
||||||
if _external_pyjhora_python_cached:
|
|
||||||
return _external_pyjhora_python_value
|
|
||||||
_external_pyjhora_python_value = _lookup_external_pyjhora_python()
|
|
||||||
_external_pyjhora_python_cached = True
|
|
||||||
return _external_pyjhora_python_value
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_external_pyjhora_python() -> Optional[str]:
|
|
||||||
candidates = [
|
candidates = [
|
||||||
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
|
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
|
||||||
shutil.which('python3.14'),
|
shutil.which('python3.14'),
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
Generated 2026-10-15 by calling the isolated workers with PyJHora absent
|
|
||||||
(importlib.util.find_spec("jhora") is None on the Windows venv):
|
|
||||||
|
|
||||||
.venv\Scripts\python -c "import json; from scripts.annual_pyjhora_replay import _isolated_tajaka_worker, _isolated_annual_worker; req={'birth_julian_day':2447893.0,'place':{'name':'Delhi','latitude':28.6139,'longitude':77.209,'timezone':5.5,'elevation':0},'age':30,'settings':{'ayanamsa':'LAHIRI','node_mode':'mean'}}; print(json.dumps(_isolated_tajaka_worker(req), sort_keys=True)); print(json.dumps(_isolated_annual_worker(req), sort_keys=True, default=str))"
|
|
||||||
|
|
||||||
The parent adds request_hash after this body. The short-circuit must match this body.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"annual_chart_snapshot": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"annual_chart_speed_snapshot": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"effective_ayanamsa": null,
|
|
||||||
"evidence_scope": "pyjhora_behavior_only",
|
|
||||||
"mudda_dasha": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"node_mode": {
|
|
||||||
"requested": "mean",
|
|
||||||
"status": "unsupported_by_replay_adapter"
|
|
||||||
},
|
|
||||||
"parity_status": "not_multiengine_parity",
|
|
||||||
"patyayini_dasha": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"sahams": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"ayanamsa": "LAHIRI",
|
|
||||||
"node_mode": "mean"
|
|
||||||
},
|
|
||||||
"solar_return_boundary": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
},
|
|
||||||
"tajika_yogas": {
|
|
||||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
|
||||||
"status": "blocked"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"node_mode": {
|
|
||||||
"requested": "mean",
|
|
||||||
"status": "unsupported_by_replay_adapter"
|
|
||||||
},
|
|
||||||
"reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError",
|
|
||||||
"settings": {
|
|
||||||
"ayanamsa": "LAHIRI",
|
|
||||||
"node_mode": "mean"
|
|
||||||
},
|
|
||||||
"status": "blocked"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""The chart API must keep the ascendant nakshatra the engine already computed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPTS = ROOT / "scripts"
|
||||||
|
TESTS = ROOT / "tests"
|
||||||
|
for path in (SCRIPTS, TESTS):
|
||||||
|
if str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
|
||||||
|
import jyotish_api_server # noqa: E402
|
||||||
|
from test_api_server_security import _handler # noqa: E402
|
||||||
|
|
||||||
|
BEIJING = {
|
||||||
|
"year": 1990,
|
||||||
|
"month": 6,
|
||||||
|
"day": 15,
|
||||||
|
"hour": 12,
|
||||||
|
"minute": 0,
|
||||||
|
"second": 0,
|
||||||
|
"lat": 39.9042,
|
||||||
|
"lon": 116.4074,
|
||||||
|
"tz": 8,
|
||||||
|
"ayanamsa": "raman",
|
||||||
|
"node_mode": "mean",
|
||||||
|
"transit_date": "2026-10-15",
|
||||||
|
"skip_vedastro_main_entry_overview": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_chart_keeps_ascendant_nakshatra_from_the_engine(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||||
|
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jyotish_api_server,
|
||||||
|
"_attach_vedastro_main_entry_overview",
|
||||||
|
lambda result, _birth: result,
|
||||||
|
)
|
||||||
|
engine = jyotish_api_server._load_local_module("jyotish_engine")
|
||||||
|
result = _handler()._compute_chart_sync(BEIJING)
|
||||||
|
lon = result["ascendant"]["lon"]
|
||||||
|
expected = engine._nakshatra_fields(lon)
|
||||||
|
|
||||||
|
for packet in (result["ascendant"], result["modules"]["chart"]["ascendant"]):
|
||||||
|
assert packet["nakshatra"] == expected["nakshatra"]
|
||||||
|
assert packet["nakshatra_pada"] == expected["nakshatra_pada"]
|
||||||
|
assert packet["nakshatra_lord"] == expected["nakshatra_lord"]
|
||||||
|
assert result["ascendant"]["nakshatra"] == "Uttara Phalguni"
|
||||||
|
assert result["ascendant"]["nakshatra_pada"] == 1
|
||||||
|
assert result["ascendant"]["nakshatra_lord"] == "Sun"
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""Issue #64: skip useless report subprocesses and observe the heavy-compute gate."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts import api_heavy_compute_gate as gate
|
|
||||||
from scripts.annual_pyjhora_replay import (
|
|
||||||
_run_isolated_annual_replay,
|
|
||||||
_run_isolated_tajaka_year_lord,
|
|
||||||
reset_jhora_import_cache,
|
|
||||||
)
|
|
||||||
from scripts.jyotish_engine import (
|
|
||||||
_oracle_progress_snapshot,
|
|
||||||
reset_oracle_progress_snapshot_cache,
|
|
||||||
)
|
|
||||||
from scripts.tajika import (
|
|
||||||
_resolve_external_pyjhora_python,
|
|
||||||
reset_external_pyjhora_python_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
FIXTURES = ROOT / "tests" / "fixtures" / "api-chart-subprocess-gate"
|
|
||||||
|
|
||||||
|
|
||||||
class _Place:
|
|
||||||
name = "Delhi"
|
|
||||||
latitude = 28.6139
|
|
||||||
longitude = 77.209
|
|
||||||
timezone = 5.5
|
|
||||||
elevation = 0
|
|
||||||
|
|
||||||
|
|
||||||
_PROFILE = {"ayanamsa": "LAHIRI", "node_mode": "mean"}
|
|
||||||
|
|
||||||
|
|
||||||
def _without_hash(payload: dict) -> dict:
|
|
||||||
body = dict(payload)
|
|
||||||
body.pop("request_hash", None)
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_jhora_tajaka_skips_subprocess_and_matches_golden(monkeypatch) -> None:
|
|
||||||
reset_jhora_import_cache()
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False)
|
|
||||||
|
|
||||||
def _forbid_subprocess(*_args, **_kwargs):
|
|
||||||
raise AssertionError("subprocess.run must not run when jhora cannot be imported")
|
|
||||||
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess)
|
|
||||||
result = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30)
|
|
||||||
golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8"))
|
|
||||||
assert result["request_hash"]
|
|
||||||
assert _without_hash(result) == golden
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_jhora_annual_skips_subprocess_and_matches_golden(monkeypatch) -> None:
|
|
||||||
reset_jhora_import_cache()
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False)
|
|
||||||
|
|
||||||
def _forbid_subprocess(*_args, **_kwargs):
|
|
||||||
raise AssertionError("subprocess.run must not run when jhora cannot be imported")
|
|
||||||
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess)
|
|
||||||
result = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30)
|
|
||||||
golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8"))
|
|
||||||
assert result["request_hash"]
|
|
||||||
assert _without_hash(result) == golden
|
|
||||||
|
|
||||||
|
|
||||||
def test_jhora_available_still_uses_subprocess(monkeypatch) -> None:
|
|
||||||
reset_jhora_import_cache()
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: True)
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
class _Completed:
|
|
||||||
returncode = 0
|
|
||||||
stdout = json.dumps({"status": "partial_verified", "settings": {}})
|
|
||||||
|
|
||||||
def _fake_run(*_args, **_kwargs):
|
|
||||||
calls["n"] += 1
|
|
||||||
return _Completed()
|
|
||||||
|
|
||||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run)
|
|
||||||
tajaka = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30)
|
|
||||||
annual = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30)
|
|
||||||
assert calls["n"] == 2
|
|
||||||
assert tajaka["request_hash"]
|
|
||||||
assert annual["request_hash"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_pyjhora_interpreter_lookup_is_cached(monkeypatch) -> None:
|
|
||||||
reset_external_pyjhora_python_cache()
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
def _missing():
|
|
||||||
calls["n"] += 1
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr("scripts.tajika._lookup_external_pyjhora_python", _missing)
|
|
||||||
assert _resolve_external_pyjhora_python() is None
|
|
||||||
assert _resolve_external_pyjhora_python() is None
|
|
||||||
assert calls["n"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_oracle_progress_snapshot_reuses_until_the_file_changes(monkeypatch) -> None:
|
|
||||||
reset_oracle_progress_snapshot_cache()
|
|
||||||
calls = {"n": 0}
|
|
||||||
real_run = __import__("subprocess").run
|
|
||||||
|
|
||||||
def _counting_run(*args, **kwargs):
|
|
||||||
calls["n"] += 1
|
|
||||||
return real_run(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr("subprocess.run", _counting_run)
|
|
||||||
first = _oracle_progress_snapshot()
|
|
||||||
after_first = calls["n"]
|
|
||||||
assert after_first >= 1
|
|
||||||
second = _oracle_progress_snapshot()
|
|
||||||
assert calls["n"] == after_first
|
|
||||||
assert second == first
|
|
||||||
|
|
||||||
oracle = ROOT / "references" / "oracle" / "dasha_shadbala_oracle_cases.json"
|
|
||||||
stat = oracle.stat()
|
|
||||||
later = stat.st_mtime + 5
|
|
||||||
try:
|
|
||||||
oracle.touch()
|
|
||||||
import os
|
|
||||||
os.utime(oracle, (later, later))
|
|
||||||
third = _oracle_progress_snapshot()
|
|
||||||
assert calls["n"] > after_first
|
|
||||||
assert third["scope"] == first["scope"]
|
|
||||||
finally:
|
|
||||||
os_utime = __import__("os").utime
|
|
||||||
os_utime(oracle, (stat.st_atime, stat.st_mtime))
|
|
||||||
reset_oracle_progress_snapshot_cache()
|
|
||||||
|
|
||||||
|
|
||||||
def test_chart_overview_gate_is_off_by_default(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv(gate.ENV_GATE_CHART_OVERVIEW, raising=False)
|
|
||||||
gate.reset_heavy_compute_gate(limit=1)
|
|
||||||
held = gate.acquire_heavy_compute_slot("/api/high_rigor_workflow")
|
|
||||||
assert held is not None
|
|
||||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1990}) is None
|
|
||||||
gate.release_heavy_compute_slot(held)
|
|
||||||
assert gate.heavy_compute_status()["in_use"] == 0
|
|
||||||
gate.reset_heavy_compute_gate()
|
|
||||||
|
|
||||||
|
|
||||||
def test_chart_overview_gate_only_sync_unskipped_requests(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv(gate.ENV_GATE_CHART_OVERVIEW, "1")
|
|
||||||
gate.reset_heavy_compute_gate(limit=1)
|
|
||||||
body = {"year": 1990, "lat": 28.6, "lon": 77.2}
|
|
||||||
slot = gate.acquire_heavy_compute_slot_for_request("/api/chart", body)
|
|
||||||
assert slot is not None
|
|
||||||
assert gate.heavy_compute_status() == {"limit": 1, "in_use": 1}
|
|
||||||
with pytest.raises(gate.HeavyComputeBusy):
|
|
||||||
gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1991})
|
|
||||||
gate.release_heavy_compute_slot(slot)
|
|
||||||
assert gate.heavy_compute_status()["in_use"] == 0
|
|
||||||
assert gate.acquire_heavy_compute_slot_for_request(
|
|
||||||
"/api/chart", {"skip_vedastro_main_entry_overview": True}
|
|
||||||
) is None
|
|
||||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"async": True}) is None
|
|
||||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"enqueue": 1}) is None
|
|
||||||
assert "/api/chart" not in gate.HEAVY_COMPUTE_PATHS
|
|
||||||
gate.reset_heavy_compute_gate()
|
|
||||||
|
|
||||||
|
|
||||||
def test_busy_log_names_holders_and_not_the_body(caplog) -> None:
|
|
||||||
gate.reset_heavy_compute_gate(limit=1)
|
|
||||||
held = gate.acquire_heavy_compute_slot("/api/professional_report_reference")
|
|
||||||
assert held is not None
|
|
||||||
secret_body = {"birth_date": "1990-01-01", "name": "must-not-appear"}
|
|
||||||
with caplog.at_level(logging.WARNING, logger="jyotish.heavy_compute"):
|
|
||||||
with pytest.raises(gate.HeavyComputeBusy):
|
|
||||||
gate.acquire_heavy_compute_slot_for_request("/api/professional_report_reference", secret_body)
|
|
||||||
text = caplog.text
|
|
||||||
assert "/api/professional_report_reference" in text
|
|
||||||
assert "1990-01-01" not in text
|
|
||||||
assert "must-not-appear" not in text
|
|
||||||
gate.release_heavy_compute_slot(held)
|
|
||||||
assert gate.heavy_compute_status()["in_use"] == 0
|
|
||||||
gate.reset_heavy_compute_gate()
|
|
||||||
|
|
||||||
|
|
||||||
def test_warm_consultation_still_skips_the_slot() -> None:
|
|
||||||
gate.reset_heavy_compute_gate(limit=1)
|
|
||||||
assert gate.acquire_heavy_compute_slot_for_request(
|
|
||||||
"/api/consultation_workflow", {"consult_card_warm_only": True}
|
|
||||||
) is None
|
|
||||||
assert gate.heavy_compute_status()["in_use"] == 0
|
|
||||||
gate.reset_heavy_compute_gate()
|
|
||||||
@@ -139,7 +139,10 @@ def test_numeric_birth_spellings_share_one_chart_cache_key():
|
|||||||
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
|
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
|
||||||
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
|
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
|
||||||
assert skipped not in keys
|
assert skipped not in keys
|
||||||
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
|
# 原值:cache_schema_version == 6
|
||||||
|
# 新值:7
|
||||||
|
# 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。
|
||||||
|
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 7
|
||||||
|
|
||||||
|
|
||||||
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
|
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -298,11 +298,8 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
|
|||||||
route = SYNASTRY_ROUTE.read_text(encoding="utf-8")
|
route = SYNASTRY_ROUTE.read_text(encoding="utf-8")
|
||||||
for token in (
|
for token in (
|
||||||
'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"',
|
'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"',
|
||||||
# 原值: postPython("/api/chart", selfPayload) 与 postPython("/api/chart", partnerPayload)
|
'postPython("/api/chart", selfPayload)',
|
||||||
# 新值: 两次调用都展开对应 payload,并带 skip_vedastro_main_entry_overview: true
|
'postPython("/api/chart", partnerPayload)',
|
||||||
# 原因: 合盘改为同时排本人和对方两张盘,并关掉用不上的外部概览。断言仍锁住这两次 /api/chart,同时锁住这个开关,不放宽。
|
|
||||||
'postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })',
|
|
||||||
'postPython("/api/chart", { ...partnerPayload, skip_vedastro_main_entry_overview: true })',
|
|
||||||
'postPython("/api/varga_full"',
|
'postPython("/api/varga_full"',
|
||||||
'postPython("/api/synastry"',
|
'postPython("/api/synastry"',
|
||||||
"moonLongitude(selfChart)",
|
"moonLongitude(selfChart)",
|
||||||
|
|||||||
Reference in New Issue
Block a user