Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 9324253408 fix(consult): move the answer-effort probe out of the image build
The dry-run script lived under frontend/scripts and read a test fixture, so the web image build would type-check a file the image does not copy. Move it to frontend/tests with a non-test filename. The switch default is unchanged.

Checked: guard plus answer-effort tests 5 pass, 0 fail, 0 cancelled; probe --dry-run exit 0; tsc exit 0; lint 0 errors and 123 warnings.

Co-Authored-By: Grok (agent loop)
2026-10-15 10:56:22 +08:00
jesse-uxandGrok (agent loop) 0d73cd332b merge origin/staging into the answer-effort branch (issue #65)
Keep the pushed switch commit and take the newer staging history, including the bounce brief.

Co-Authored-By: Grok (agent loop)
2026-10-15 10:42:03 +08:00
Claude 71f7fac3a1 merge origin/staging into chart ascendant row (issue #53)
Independent Staging Quality Gate / validate (push) Successful in 13m39s
Independent Staging Quality Gate / publish (push) Successful in 3m47s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 10:14:36 +08:00
jesse-uxGrok (agent loop)Cursor
454951de27 fix(chart): keep the ascendant nakshatra the page already renders
The chart API rebuilt the ascendant without nakshatra, lord, and pada, so those cells stayed blank. Cache schema is now 7. Centered column headers line up with their cells, and the current dasha card keeps its tint without the left accent.

pytest 34 passed. tsc 0 errors. lint 0 errors and 123 existing warnings.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:46:37 +08:00
jesse-uxGrok (agent loop)Cursor
ab7f66be14 feat(consult): add an answer-step reasoning effort switch
The switch is off unless CONSULT_ANSWER_REASONING_EFFORT is set, so today's request body stays the same. A set value is sent as top-level reasoning_effort only on the answer step. The real A/B did not run: there is no model key.

Request-body probe, the named frontend tests (114 pass, 0 fail), tsc, and lint (0 errors) were run.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:31:19 +08:00
43 changed files with 1056 additions and 1219 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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`。
+5 -5
View File
@@ -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` |
+43
View File
@@ -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
View File
@@ -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,
+22 -5
View File
@@ -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;
}; };
+14 -18
View File
@@ -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),
+8 -2
View File
@@ -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
+86 -5
View File
@@ -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;
+203
View File
@@ -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;
}
});
+1
View File
@@ -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);
+8 -1
View File
@@ -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, []);
});
+8 -1
View File
@@ -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
View File
@@ -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": {
-103
View File
@@ -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");
});
-49
View File
@@ -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},
+10 -99
View File
@@ -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()
+24 -7
View File
@@ -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': {
+2 -26
View File
@@ -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
-291
View File
@@ -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()
-21
View File
@@ -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'),
-6
View File
@@ -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"
-197
View File
@@ -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()
+4 -1
View File
@@ -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):
+2 -5
View File
@@ -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)",