diff --git a/BLOCKED.md b/BLOCKED.md index 831cf41c..1ab9a618 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -8,6 +8,11 @@ - **恢复演练**:内测开放前按 `docs/operations/production-restore-drill-20261014.md` 做一次一次性库还原,结果填 `docs/testing/production-restore-drill-20261014.md`。模板现在是空的。 - 解除:上面四件事做完,并把演练模板里的「是否通过」填上。不要把 Webhook、密钥或用户内容写进仓库。 +## 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) - **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c2f28a..3e0cad6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## 2026-10-15 — 内测前补上每日备份、宕机告警和日志上限(待合入 staging) -- 生产可以每天自动做一次加密备份。站点连续三次打不开、磁盘过满,或短时间错误变多时,可以通知到飞书群。容器日志有大小上限。可以按一轮请求的编号在服务器日志里查找。这些要等产品在主机上装好之后才生效。页面没有变化。Skill 版本不变(BUG-1354、BUG-1355、BUG-1356)。 +- 生产可以每天自动做一次加密备份。站点连续三次打不开、磁盘过满,或短时间错误变多时,可以通知到飞书群。容器日志有大小上限。可以按一轮请求的编号在服务器日志里查找。这些要等产品在主机上装好之后才生效。页面没有变化。Skill 版本不变(BUG-1355、BUG-1356、BUG-1357)。 + +## 2026-10-15 — 合盘更快(待合入 staging) + +- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。 ## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 481957cd..1c135ef4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15304,17 +15304,17 @@ ## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具 - 状态: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 停在上一版。 - 用户现象:盘型口径(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/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/`。 - 验证:按 `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。 -- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。 -- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。 +- 复发自:无。2026-10-14 与 2026-10-15 都是同一形状,被守卫拦住,未进测试环境。 +- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。2026-10-15 的同类挪动在 `codex/answer-effort-ab-20261013`。 ## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究) @@ -17991,6 +17991,36 @@ - 复发自:无。 - 修复版本:分支 `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 | 个人长报告的中文出稿和溯源哈希重复劳动 - 状态:investigating @@ -33372,17 +33402,17 @@ ## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具 - 状态: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 停在上一版。 - 用户现象:盘型口径(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/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/`。 - 验证:按 `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。 -- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。 -- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。 +- 复发自:无。2026-10-14 与 2026-10-15 都是同一形状,被守卫拦住,未进测试环境。 +- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。2026-10-15 的同类挪动在 `codex/answer-effort-ab-20261013`。 ## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究) @@ -36044,6 +36074,36 @@ - 复发自:无。 - 修复版本:分支 `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 | 个人长报告的中文出稿和溯源哈希重复劳动 - 状态:investigating @@ -36182,7 +36242,22 @@ - 复发自:无 - 修复版本:分支 `codex/chart-ascendant-row-20261015`。 -## BUG-1354 | 生产没有每日自动备份 +## 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 之后。 + +## BUG-1355 | 生产没有每日自动备份 - 状态:代码已交,主机安装待产品 - 首次发现 / 最近更新:2026-10-14 / 2026-10-15 @@ -36193,37 +36268,37 @@ - 修复:新增只从 `main` 运行的每日备份工作流,复用现有加密与还原校验脚本。每日文件用单独前缀,过期清理不会碰到手动恢复点。工作流要等进入 `main` 后才会在北京时间 04:00 跑。本机没有生产主机,没有手动触发。 - 验证:合同测试锁定有每日时刻、只在 `main` 上跑、调用同一个恢复脚本、清理只删过期的每日前缀。虚构目录里,手动恢复点和其他文件都还在。 - 防复发:上述合同测试。清理函数不接受外部传入的前缀。 -- 相关记录:BUG-1350、BUG-1355、BUG-1356。 +- 相关记录:BUG-1350、BUG-1356、BUG-1357。 - 复发自:无。 -- 修复版本:分支 `codex/beta-safety-net-20261014`。开工时本文件最大号是 BUG-1353。 +- 修复版本:分支 `codex/beta-safety-net-20261014`。开工时本文件最大号是 BUG-1353,当时写成 BUG-1354。合并 staging 后该号已被更早的答题强度记录占用,按台账规则改为 BUG-1355。 -## BUG-1355 | 站点打不开或磁盘过满时没有告警 +## BUG-1356 | 站点打不开或磁盘过满时没有告警 - 状态:代码已交,主机安装待产品 - 首次发现 / 最近更新:2026-10-14 / 2026-10-15 -- 来源:同 BUG-1354。 +- 来源:同 BUG-1355。 - 现象:容器自己的健康检查只会尝试重启,不会通知人。磁盘变满也没有通知。 - 触发条件:公网健康检查连续失败,或生产主机磁盘使用率达到 80%。 - 根因:上线时没有持续探活,也没有把告警发到人用的渠道。 - 修复:探活脚本连续 3 次失败才发一条告警,恢复只发一次。磁盘检查单独在生产主机上跑。Webhook 从环境文件读取,没有配置就只写本机日志。外发正文不含用户内容。脚本还没装到 runner 或生产主机上。 - 验证:本地桩服务覆盖正常、500、超时和缺少字段。连续 3 次才告警,恢复只出现一次。签名测试里的密钥没有出现在发出的正文里。 - 防复发:上述脚本测试。告警行若含不允许的字段,脚本拒绝发送。 -- 相关记录:BUG-1354、BUG-1356。 +- 相关记录:BUG-1355、BUG-1357。 - 复发自:无。 -- 修复版本:分支 `codex/beta-safety-net-20261014`。编号接在 BUG-1354 之后。 +- 修复版本:分支 `codex/beta-safety-net-20261014`。原先写成 BUG-1355,合并 staging 后顺延为 BUG-1356。 -## BUG-1356 | 容器日志不轮转,也不能按一轮请求查找 +## BUG-1357 | 容器日志不轮转,也不能按一轮请求查找 - 状态:代码已交,主机安装待产品 - 首次发现 / 最近更新:2026-10-14 / 2026-10-15 -- 来源:同 BUG-1354。BUG-1306 取证时日志只在容器控制台,重建后查不到。 +- 来源:同 BUG-1355。BUG-1306 取证时日志只在容器控制台,重建后查不到。 - 现象:编排没有日志大小上限。知道一轮请求的编号时,没有只读的查找命令。 - 触发条件:日志持续写入,或需要按 requestId 回看 web、api、caddy。 - 根因:日志沿用守护进程的未知默认值,也没有按编号检索的入口。 - 修复:每个服务的日志改为单文件 20 MB、最多 10 个文件。新增只读查找脚本,只接受 UUID。错误汇总只计数和最多 5 个编号,丢掉含邮箱、额外日期或非 ASCII 文本的行。要等生产容器按新编排重建后,轮转才生效。 - 验证:两个编排文件里每个服务的日志段一致。虚构日志能按时间排序找到编号;命令注入写法被拒绝,旁边的标记文件还在。含邮箱和汉字的样本没有进入告警文本。 - 防复发:上述合同测试和查找脚本的格式检查。 -- 相关记录:BUG-1306、BUG-1354、BUG-1355。 +- 相关记录:BUG-1306、BUG-1355、BUG-1356。 - 复发自:无。 -- 修复版本:分支 `codex/beta-safety-net-20261014`。编号接在 BUG-1355 之后。 +- 修复版本:分支 `codex/beta-safety-net-20261014`。原先写成 BUG-1356,合并 staging 后顺延为 BUG-1357。 diff --git a/docs/tasks/PROGRESS-answer-effort-ab-20261013.md b/docs/tasks/PROGRESS-answer-effort-ab-20261013.md new file mode 100644 index 00000000..b93839fe --- /dev/null +++ b/docs/tasks/PROGRESS-answer-effort-ab-20261013.md @@ -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`。 diff --git a/docs/tasks/PROGRESS-answer-effort-ab-fix-20261015.md b/docs/tasks/PROGRESS-answer-effort-ab-fix-20261015.md new file mode 100644 index 00000000..b6a48f5b --- /dev/null +++ b/docs/tasks/PROGRESS-answer-effort-ab-fix-20261015.md @@ -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` 里原有的这一条保留,没有新增。 diff --git a/docs/tasks/PROGRESS-api-chart-subprocess-gate-20261013.md b/docs/tasks/PROGRESS-api-chart-subprocess-gate-20261013.md new file mode 100644 index 00000000..93f936a1 --- /dev/null +++ b/docs/tasks/PROGRESS-api-chart-subprocess-gate-20261013.md @@ -0,0 +1,90 @@ +# 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`。 diff --git a/docs/tasks/PROGRESS-beta-safety-net-20261014.md b/docs/tasks/PROGRESS-beta-safety-net-20261014.md index b174979b..b2d37131 100644 --- a/docs/tasks/PROGRESS-beta-safety-net-20261014.md +++ b/docs/tasks/PROGRESS-beta-safety-net-20261014.md @@ -3,7 +3,7 @@ - 分支:`codex/beta-safety-net-20261014` - 基线:开工时 `origin/staging` 为 `71f7fac3a13f817f91755ff01b25b8706380ae19` - 前置:`09906cdc`(发布通路)和 `4c3485bc`、`3893783e`(#58)都已是这个基线的祖先。`deploy/docker-compose.server.yml` 在加日志段之前没有 #58 留下的未合入冲突。 -- 编号:本文件最大号是 BUG-1353。本单用 BUG-1354(没有每日备份)、BUG-1355(没有探活和磁盘告警)、BUG-1356(日志不轮转、不能按请求编号查)。 +- 编号:本文件最大号是 BUG-1353。本单当时写成 BUG-1354(没有每日备份)、BUG-1355(没有探活和磁盘告警)、BUG-1356(日志不轮转、不能按请求编号查)。合并 staging 后 BUG-1354 已被更早的答题强度记录占用,三条顺延为 BUG-1355、BUG-1356、BUG-1357。 ## 做了什么 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index baf4927e..b31d3ab7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -338,8 +338,10 @@ | `TASK-release-path-production-20261014.md` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 第 2 次打回后续修 | `codex/release-path-production-20261014`(BUG-1348、BUG-1349) | | `TASK-release-path-production-fix-20261014.md` | `PROGRESS-release-path-production-fix-20261014.md` | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 已修,第 2 次仍未过 | `codex/release-path-production-20261014` | | `TASK-release-path-production-fix2-20261014.md` | `PROGRESS-release-path-production-fix2-20261014.md` | **#74 修复单 2(产品选 A)**:恢复点要带属主与权限,旧格式不得回退;演练按备份里的授权比对,并在真实 PostgreSQL 17 上跑通一次 | 待验收 | `codex/release-path-production-20261014` | -| `TASK-beta-safety-net-20261014.md` | `PROGRESS-beta-safety-net-20261014.md` | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。发布通路与 #58 已在开工时的 `origin/staging` 上。主机安装和一次演练留给产品 | 待验收 | `codex/beta-safety-net-20261014`(BUG-1354~1356) | +| `TASK-beta-safety-net-20261014.md` | `PROGRESS-beta-safety-net-20261014.md` | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。发布通路与 #58 已在开工时的 `origin/staging` 上。主机安装和一次演练留给产品 | 待验收 | `codex/beta-safety-net-20261014`(BUG-1355~1357) | +| `TASK-beta-safety-net-fix-20261015.md` | — | **#75 产品选 A**:授权新增每日备份定时工作流,但每日备份不得改写 `recovery-baseline.env`(恢复点仍只手动创建);另记 `daily-backup-latest.env` | 待领取 | `codex/beta-safety-net-20261014` | | `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — | +| `TASK-beta-capacity-fix-20261015.md` | — | **#76 第 1 次打回**:`a54f75e5` 限流与短排队逻辑通过;源码文本断言未跟改、插入新测试使隐私白名单行号错位 | 待领取 | `codex/beta-capacity-20261014` | | `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) | | `TASK-ephemeris-nutation-20261008.md` | `PROGRESS-ephemeris-nutation-20261008.md` | **恒星黄道少扣章动(BUG-1275)**:行星 = 含章动热带视位置 − 平岁差,全体偏 Δψ(≤17″),大运日期差 2–3 天;改标准算法、PyJHora 冻结对照、scoring-12、v5 重跑 | 已验收(已部署 staging `db0134f9`,BUG-1275) | `codex/ephemeris-nutation-20261008`(未推送,未部署) | | `TASK-sync6-gate-red-20261007.md` | — | **sync6 后门禁仍红**:run 3213 快速门已过,「Frontend and database tests」开跑 20 秒后日志中断、15 分钟后判失败,后续步骤 failure 0 秒;疑运行机掉线;先 Re-run,红则本机带 Docker 跑 `CI=1 npm test` 对照 `0ed45a4b` | 已完成(Re-run 通过,BUG-1271,部署 `2df6ee72`) | — | @@ -483,10 +485,10 @@ | `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-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-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` | — | **#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-fix-20261015.md` | — | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140) | 待领取 | `codex/answer-effort-ab-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-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-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` | `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-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` | diff --git a/docs/tasks/TASK-beta-capacity-fix-20261015.md b/docs/tasks/TASK-beta-capacity-fix-20261015.md new file mode 100644 index 00000000..07c298db --- /dev/null +++ b/docs/tasks/TASK-beta-capacity-fix-20261015.md @@ -0,0 +1,37 @@ +# TASK · 内测容量修复单(issue #76,第 1 次打回) + +- 日期:2026-10-15 +- 原任务书:`docs/tasks/TASK-beta-capacity-20261014.md` +- 被验 SHA:`a54f75e5e33775d5dc91f3058f5505a56b498bf2`(`codex/beta-capacity-20261014`) +- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史) + +## 1. 验收结论:逻辑通过,全量多出 2 条失败 + +| 检查 | 结果 | 证据 | +| --- | --- | --- | +| 按用户限流 | 通过 | 问、校正每轮、报告每小时各有分钟 / 小时两档;任一档满就整体拒,拒绝不记账;都在扣点之前 | +| 重计算短排队 | 通过 | 并发仍是 2(BUG-717);最多 4 个等待、每个最多 8 秒,等不到仍 429(BUG-720 有上限) | +| 前端排队重试 | 通过 | 最多 2 次、总等待 ≤ 15 秒;最终失败走原取消路径 | +| tsc / lint | 通过 | — | +| **前端全量** | **未通过:新增 1 条** | `frontend/tests/consultation-voice-contract.test.ts`「consult user turn no longer dumps tool JSON that the model would parrot」:`/await runConsultationWorkflow\(toolInput/` 不再匹配——`route.ts` 改成了 `await retryOnConsultationRateLimit({ run: () => runConsultationWorkflow(toolInput, …) })` | +| **Python 全量** | **未通过:新增 1 条** | `tests/test_repo_privacy_markers.py::test_tracked_repository_has_no_privacy_markers`:`HOME_PREFIX frontend/tests/personal-report-api.test.ts lines=845,873,903` | + +第 2 条不是真的隐私泄漏:这三行是已有的、经审过的虚构路径样本,`tests/test_repo_privacy_markers.py` 的 `SAFE_LINES` 按「文件 + 行号 + 整行」锁定它们。本单在它们上面插入了一条 13 行的新测试,行号整体下移,白名单就对不上了。 + +## 2. 修法要求 + +1. `consultation-voice-contract.test.ts`:按 §7.3 写三栏改断言,**意图不放宽**——仍要锁住「工作流调用的入参是 `toolInput`」与该测试原本关心的那段(用户轮不再塞工具 JSON);新正则匹配 `runConsultationWorkflow(toolInput` 即可,不得去掉对 `toolInput` 的锁定。 +2. 隐私白名单二选一(推荐 a): + a. 把新加的「hourly rate limit returns 429」测试挪到文件末尾,让那三行回到原行号,`SAFE_LINES` 不动; + b. 更新 `SAFE_LINES` 里这三行的行号,按三栏写原值 / 新值 / 原因。 +3. 再 grep 一遍 `route.ts` / `rectification/agent/route.ts` / `reports/route.ts` 被改写语句在 `frontend/tests`、`tests` 里的源码文本断言(§7.8)。 +4. 交回前本机跑:`npx tsx --test tests/consultation-voice-contract.test.ts tests/runtime-sources-no-test-imports.test.ts` 与 `python -m pytest tests/test_repo_privacy_markers.py -q`。 +5. 业务代码不改。 + +## 3. 环境缺口(不阻断) + +`frontend/scripts/research/beta-load-smoke.mts` 并发压测未跑(无测试验证码、无预发主机)。合入部署后由 Claude 在验收清单里列给产品负责人,或另开一单在 staging 跑。 + +## 4. 让步顺序 + +无。第 1 次打回。 diff --git a/docs/tasks/TASK-beta-safety-net-fix-20261015.md b/docs/tasks/TASK-beta-safety-net-fix-20261015.md new file mode 100644 index 00000000..e6d862aa --- /dev/null +++ b/docs/tasks/TASK-beta-safety-net-fix-20261015.md @@ -0,0 +1,31 @@ +# TASK · 内测安全网修复单(issue #75,产品选 A) + +- 日期:2026-10-15 +- 原任务书:`docs/tasks/TASK-beta-safety-net-20261014.md` +- 被验 SHA:`53d8df257ddef8918034acef36df0b4b7daade2c`(`codex/beta-safety-net-20261014`) +- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史) +- **决策记录**:产品负责人 2026-10-15 在 issue #75 选 **A**——**授权新增定时工作流 `.gitea/workflows/backup-production-daily.yml`**(每天北京时间 04:00 自动连生产做加密备份),但**每日备份不得改动「当前恢复点」记录**。恢复点仍只由产品负责人手动点「Create production recovery」创建。仍不改已有四个生产工作流的语义;仍不点任何生产按钮。 + +## 1. 问题 + +`deploy/run-production-recovery.sh` 无论由谁调用,结尾都会无条件写 `$DEPLOY_PATH/.state/recovery-baseline.env`(`RECOVERY_REFERENCE`、`RECOVERY_SHA256`、`RECOVERY_FORMAT=2` 等)。发布、迁移、回退都读这份文件当「当前恢复点」。每日备份复用这个脚本,于是每天 04:00 都会把它换成当天的自动备份——产品负责人上线前手动建的恢复点,隔一夜就被替换。 + +## 2. 修法要求 + +1. **每日备份不写 `recovery-baseline.env`**:`RECOVERY_BASENAME_PREFIX=production-daily` 时(或新增一个明确的模式参数,二选一,写进 PROGRESS),脚本仍完整执行「导出 → 加密 → 还原核对(含 #74 的表权限、列权限、属主三项比对)→ 写 `*-restore-verify.json`」,但**跳过写 `recovery-baseline.env`**,并在输出里打印 `baseline_updated=false`。手动恢复点路径行为不变,打印 `baseline_updated=true`。 +2. 每日备份的核对结果单独记录:在 `$DEPLOY_PATH/.state/` 下另写一个 `daily-backup-latest.env`(只含文件名、sha256、校验时间、格式版本,不含数据、不含密钥),供告警 / 运维查看;发布、迁移、回退工作流**不得**读取它。 +3. 合同测试(`tests/test_beta_safety_net.py` 或 `frontend/tests/staging-backend-workflows.test.ts`): + - 每日模式下 `recovery-baseline.env` 不被创建或修改(用临时目录 + 假 docker 跑脚本,断言文件 mtime / 内容不变); + - 手动模式下仍会写,且含 `RECOVERY_FORMAT=2`; + - 三个读取恢复点的生产工作流 / 脚本不引用 `daily-backup-latest.env`。 +4. runbook 与 `deploy/README.md`:写清「每日备份只做存档与核对,不是发布用的恢复点;上线前仍要手动点 Create production recovery」。 +5. 清理脚本只删 `production-daily-*` 前缀的文件(原任务书已要求)——再确认不会删 `production-pre-migration-*` 手动恢复点。 + +## 3. 验收 + +- Claude 会对本单做完整验收(上一轮在发现定时工作流后就停下等拍板,其余部分尚未逐行验):逐行看新工作流与各脚本、跑 `tests/test_beta_safety_net.py`、Python 全量、前端全量、`docker compose config`。 +- 交回前本机跑:`python -m pytest tests/test_beta_safety_net.py tests/test_repo_privacy_markers.py -q`、`npx tsx --test tests/runtime-sources-no-test-imports.test.ts tests/staging-backend-workflows.test.ts`。 + +## 4. 让步顺序 + +无。 diff --git a/docs/testing/answer-effort-ab-20261013.md b/docs/testing/answer-effort-ab-20261013.md new file mode 100644 index 00000000..a476c414 --- /dev/null +++ b/docs/testing/answer-effort-ab-20261013.md @@ -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、人工四项都没做。默认值维持不设。 diff --git a/docs/testing/api-chart-subprocess-gate-ab-20261013.json b/docs/testing/api-chart-subprocess-gate-ab-20261013.json new file mode 100644 index 00000000..44c6b5be --- /dev/null +++ b/docs/testing/api-chart-subprocess-gate-ab-20261013.json @@ -0,0 +1,53 @@ +{ + "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 +} diff --git a/docs/testing/api-chart-subprocess-gate-ab-20261013.md b/docs/testing/api-chart-subprocess-gate-ab-20261013.md new file mode 100644 index 00000000..55a88cb8 --- /dev/null +++ b/docs/testing/api-chart-subprocess-gate-ab-20261013.md @@ -0,0 +1,40 @@ +# 合盘排盘与报告子进程 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,越过了现在的上限。 diff --git a/frontend/scripts/research/consult-biography-backtest.mts b/frontend/scripts/research/consult-biography-backtest.mts index 2083df7f..56ed30bd 100644 --- a/frontend/scripts/research/consult-biography-backtest.mts +++ b/frontend/scripts/research/consult-biography-backtest.mts @@ -27,6 +27,7 @@ * Usage (from frontend/, Node 22): * DS_KEY=... ./node_modules/.bin/tsx scripts/research/consult-biography-backtest.mts \ * [--out ] [--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] * [--correction barack_obama,marilyn_monroe] [--correction-text 我爸其实没怎么管过我] * [--correction-domain career] [--correction-texts "zinedine_zidane=我是足球运动员|barack_obama=我是政治家"] @@ -142,6 +143,8 @@ function parseArgs(argv: readonly string[]) { const at = pair.indexOf("="); return [pair.slice(0, at), pair.slice(at + 1)]; })) as Record, + 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, apiKey: string): ResolvedLang /** A follow-up turn in the same session: the earlier question and answer are the history. */ 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 }; @@ -316,6 +327,22 @@ function userTurn(question: string, followUp?: FollowUp) { } 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 executed: string[] = []; const ctx = agentContext(job, state, executed); @@ -333,7 +360,14 @@ async function runJob(job: Job, resolved: ResolvedLanguageModel) { maxSteps: AGENT_MAX_STEPS, ...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[] = []; const startedAt = Date.now(); let output: string | null = null; @@ -417,6 +451,17 @@ async function runJob(job: Job, resolved: ResolvedLanguageModel) { methodologySections: state.methodologySectionCount, lookups: state.evidenceLookupCallCount, 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); }; -type Row = { figure: string; domain: string; run: number; chars: number; seconds: number; checks: ReturnType }; +type Row = { + figure: string; + domain: string; + run: number; + chars: number; + seconds: number; + checks: ReturnType; + 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) { const rows = summary.rows; @@ -488,13 +544,13 @@ function writeSummary(out: string, summary: Json & { rows: Row[] }, modelId: str writeFileSync(join(out, "summary.md"), [ `# 名人生平回测 · ${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)}`, `确定性检查:禁句 ${summary.banned_hits},性别代词(婚恋/子女)${summary.pronoun_hits},must_not 原文 ${summary.must_not_literal_hits}。判断类评分需逐份阅读。`, "", - "| 名人 | 领域 | run | 字数 | 秒 | 禁句 | 代词 | must_not |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ...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} |`), + "| 名人 | 领域 | run | 字数 | 秒 | 空答 | 截断 | 第0步秒 | 第1步秒 | 第1步推理token | 结束 |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ...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 原文命中", "", @@ -529,11 +585,28 @@ async function main() { } 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 jobs: Job[] = figures.flatMap((figure) => args.domains.flatMap((domain) => { - const question = questions.get(domain); - if (!question) throw new Error(`no golden question for domain ${domain}`); - return Array.from({ length: args.repeats }, (_, index) => ({ figure, domain, question, run: index + 1 })); - })); + const pairs = args.cases + ? args.cases.map((spec) => { + const splitAt = spec.indexOf(":"); + 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 }); if (args.dumpCards) { 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."); const resolved = model(args, apiKey); const started = Date.now(); - const rows = await pool(jobs, args.concurrency, async (job) => { + const rows = await pool(jobs, concurrency, async (job) => { let result: Awaited>; try { result = await runJob(job, resolved); } 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 checks = deterministicChecks(result.output, job.domain, mustNot); @@ -573,7 +646,18 @@ async function main() { "", ].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}` : ""}`); - 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( jobs.filter((job) => args.correctionDomains.includes(job.domain) @@ -589,7 +673,7 @@ async function main() { try { result = await runJob(job, resolved); } 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`); writeFileSync(file, [ @@ -619,6 +703,9 @@ async function main() { jobs: rows.length, failures: rows.filter((row) => row.failure || row.chars === 0).length, usage: total, + reasoning_effort: process.env.CONSULT_ANSWER_REASONING_EFFORT?.trim() || null, + efforts: args.efforts, + concurrency, gaps: GAPS, rows, corrections, diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index e9e6a056..db0478b4 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -75,6 +75,8 @@ import { createConsultationRunClock, consultationContinueGenerationSettings, consultationGenerationSettings, + consultationGeneralPrepareStep, + consultAnswerEffortProviderOptions, consultationNatalPrepareStep, consultationWindowPrepareStep, createConsultationAgentContext, @@ -1288,6 +1290,8 @@ export async function POST(request: Request) { prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({ stepNumber: input.stepNumber, rectificationOffer: true, + answerReady: state.consultationToolCompleted, + model: selectedModel.model, }), }; const windowStreamOptions = { @@ -1295,6 +1299,15 @@ export async function POST(request: Request) { prepareStep: (input: { stepNumber: number }) => consultationWindowPrepareStep({ stepNumber: input.stepNumber, 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; @@ -1305,10 +1318,10 @@ export async function POST(request: Request) { agent: { stream: ( messages: typeof baseMessages, - options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions, + options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions | typeof generalStreamOptions, ) => Promise<{ fullStream: AsyncIterable | ReadableStream; totalUsage: Promise }>; }, - options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions, + options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions | typeof generalStreamOptions = streamOptions, ) { try { const result = await agent.stream(baseMessages, options); @@ -1369,14 +1382,14 @@ export async function POST(request: Request) { if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) { state.workflowReceipt = workflowReceipt; 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, // so there is no contract for a retry to repair. const retryForAnswer = async (retryHint?: string) => { const retried = await agent.stream([ ...baseMessages, { role: "user" as const, content: `上一轮没有输出任何回答文本。请直接给出这个问题的回答,不要只说明过程。${retryHint ? `\n${retryHint}` : ""}` }, - ], { ...streamOptions, abortSignal: answerPhaseSignal() }); + ], { ...generalStreamOptions, abortSignal: answerPhaseSignal() }); usages.push(retried.totalUsage); return retried.fullStream; }; @@ -1472,7 +1485,11 @@ export async function POST(request: Request) { role: "user" as const, content: `服务器窗口计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。${retryHint ? `\n${retryHint}` : ""}`, }, - ], { ...streamOptions, abortSignal: answerPhaseSignal() }); + ], { + ...streamOptions, + abortSignal: answerPhaseSignal(), + ...consultAnswerEffortProviderOptions(selectedModel.model), + }); usages.push(retried.totalUsage); return retried.fullStream; }; diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index af742338..fa657cb4 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -183,8 +183,10 @@ export async function POST(request: Request) { synastryBirthPayload(globalBirthProfileFromAccountRow(selfResolved.row)), synastryBirthPayload(globalBirthProfileFromAccountRow(partnerResolved.row)), ]); - const selfChart = await postPython("/api/chart", selfPayload); - const partnerChart = await postPython("/api/chart", partnerPayload); + const [selfChart, partnerChart] = await Promise.all([ + postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true }), + postPython("/api/chart", { ...partnerPayload, skip_vedastro_main_entry_overview: true }), + ]); const relationshipType = parsed.data.relationshipType === "business" || parsed.data.relationshipType === "family" || parsed.data.relationshipType === "general" ? parsed.data.relationshipType : "romance"; @@ -210,18 +212,20 @@ export async function POST(request: Request) { message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。", }); } - const selfD9 = await postPython("/api/varga_full", { - ...selfPayload, - planets: selfChart.planets, - ascendant: selfChart.ascendant, - divisions: ["D9"], - }); - const partnerD9 = await postPython("/api/varga_full", { - ...partnerPayload, - planets: partnerChart.planets, - ascendant: partnerChart.ascendant, - divisions: ["D9"], - }); + const [selfD9, partnerD9] = await Promise.all([ + postPython("/api/varga_full", { + ...selfPayload, + planets: selfChart.planets, + ascendant: selfChart.ascendant, + divisions: ["D9"], + }), + postPython("/api/varga_full", { + ...partnerPayload, + planets: partnerChart.planets, + ascendant: partnerChart.ascendant, + divisions: ["D9"], + }), + ]); const synastry = await postPython("/api/synastry", { male_moon: moonLongitude(selfChart), female_moon: moonLongitude(partnerChart), diff --git a/frontend/src/lib/agent-generation-settings.ts b/frontend/src/lib/agent-generation-settings.ts index 25488402..7d3de3f6 100644 --- a/frontend/src/lib/agent-generation-settings.ts +++ b/frontend/src/lib/agent-generation-settings.ts @@ -16,6 +16,8 @@ export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS; export type ThinkingMode = "enabled" | "disabled"; 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<{ readTokens: number; @@ -100,6 +102,8 @@ export function agentGenerationSettings( answerTokens?: number; thinkingTokens?: number; reasoningEffort?: ReasoningEffort; + /** Written beside `thinking`, not inside it, so compatible gateways emit `reasoning_effort`. */ + providerReasoningEffort?: ProviderReasoningEffort; } = {}, ) { const thinkingMode: ThinkingMode = options.thinking ?? "disabled"; @@ -110,6 +114,9 @@ export function agentGenerationSettings( ? { reasoningEffort: options.reasoningEffort } : {}), }, + ...(options.providerReasoningEffort + ? { reasoningEffort: options.providerReasoningEffort } + : {}), }; const providerId = typeof model === "string" ? model diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 47d9e899..c5c3fd8f 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -25,7 +25,7 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts"; import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.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 { consultDomainConcurrency } from "../lib/consult/prep-flags.ts"; 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 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 * 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. * * 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 }) { - if (input.stepNumber !== 0) return { toolChoice: "auto" as const }; +type ConsultPrepareInput = { + 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) { return { activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID], toolChoice: "auto" as const, + ...effort, }; } return { activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID], toolChoice: "auto" as const, + ...effort, }; } -export function consultationWindowPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) { - if (input.stepNumber !== 0) return { toolChoice: "auto" as const }; +export function consultationWindowPrepareStep(input: { + 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) { return { activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID], toolChoice: "auto" as const, + ...effort, }; } return { activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID], 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_ANSWER_RESERVE_MS = 45_000; export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS; diff --git a/frontend/tests/answer-effort-probe.mts b/frontend/tests/answer-effort-probe.mts new file mode 100644 index 00000000..0b008c25 --- /dev/null +++ b/frontend/tests/answer-effort-probe.mts @@ -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 : {}; + return { + thinking: record.thinking ?? null, + reasoning_effort: record.reasoning_effort ?? null, + max_tokens: record.max_tokens ?? null, + }; +} + +export async function captureStreamBodies(input: { + settings: ReturnType; + prepareStep?: (step: { stepNumber: number }) => { toolChoice?: "auto"; activeTools?: string[]; providerOptions?: unknown }; + steps: 1 | 2; +}): Promise { + 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; + }); +} diff --git a/frontend/tests/answer-effort-switch-20261013.test.ts b/frontend/tests/answer-effort-switch-20261013.test.ts new file mode 100644 index 00000000..20589d37 --- /dev/null +++ b/frontend/tests/answer-effort-switch-20261013.test.ts @@ -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) }; + 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[]; + 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[]; + 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; + } +}); diff --git a/frontend/tests/chart-request-skip-contract.test.ts b/frontend/tests/chart-request-skip-contract.test.ts new file mode 100644 index 00000000..b24c519d --- /dev/null +++ b/frontend/tests/chart-request-skip-contract.test.ts @@ -0,0 +1,44 @@ +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, []); +}); diff --git a/frontend/tests/consult-answer-clock-20261001.test.ts b/frontend/tests/consult-answer-clock-20261001.test.ts index 4e567a49..8cac8b56 100644 --- a/frontend/tests/consult-answer-clock-20261001.test.ts +++ b/frontend/tests/consult-answer-clock-20261001.test.ts @@ -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. 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,/); }); diff --git a/frontend/tests/fixtures/answer-effort-switch-20261013.json b/frontend/tests/fixtures/answer-effort-switch-20261013.json new file mode 100644 index 00000000..a78368a3 --- /dev/null +++ b/frontend/tests/fixtures/answer-effort-switch-20261013.json @@ -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 + } + ] +} diff --git a/frontend/tests/synastry-chart-skip.test.ts b/frontend/tests/synastry-chart-skip.test.ts new file mode 100644 index 00000000..ffc03949 --- /dev/null +++ b/frontend/tests/synastry-chart-skip.test.ts @@ -0,0 +1,103 @@ +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; business: Record; 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"); +}); diff --git a/scripts/annual_pyjhora_replay.py b/scripts/annual_pyjhora_replay.py index 01d9a4d4..5f02a453 100755 --- a/scripts/annual_pyjhora_replay.py +++ b/scripts/annual_pyjhora_replay.py @@ -9,6 +9,7 @@ from __future__ import annotations import contextlib import hashlib +import importlib.util import json import subprocess import sys @@ -22,6 +23,34 @@ from typing import Any, Callable ENGINE = "PyJHora/JHora" 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" _YEAR_LORD_MODULES = ( "jhora.horoscope.dhasa.annual.mudda", @@ -139,6 +168,10 @@ def _run_isolated_tajaka_year_lord( "settings": settings, } request_hash = _request_hash(request) + if not _jhora_importable(): + replay = _no_jhora_tajaka_replay(settings) + replay["request_hash"] = request_hash + return replay command = [ sys.executable, "-c", @@ -276,6 +309,10 @@ def _run_isolated_annual_replay( "settings": settings, } request_hash = _request_hash(request) + if not _jhora_importable(): + replay = _no_jhora_annual_replay(settings) + replay["request_hash"] = request_hash + return replay command = [ sys.executable, "-c", @@ -391,6 +428,18 @@ 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]: return _annual_worker_result( {key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS}, diff --git a/scripts/api_heavy_compute_gate.py b/scripts/api_heavy_compute_gate.py index cf9f250f..0067a9b9 100644 --- a/scripts/api_heavy_compute_gate.py +++ b/scripts/api_heavy_compute_gate.py @@ -8,13 +8,17 @@ must not call this gate. from __future__ import annotations +import logging import os import threading +import time DEFAULT_CONCURRENCY = 2 DEFAULT_RETRY_AFTER_SECONDS = 2 ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY" 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( { @@ -46,6 +50,26 @@ HEAVY_COMPUTE_PATHS = frozenset( _state_lock = threading.Lock() _semaphore: threading.BoundedSemaphore | None = None _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): @@ -80,7 +104,7 @@ def reset_heavy_compute_gate( retry_after_seconds: int | None = None, ) -> None: """Rebuild the process-wide semaphore. Tests must call this after env changes.""" - global _semaphore, _retry_after_seconds + global _semaphore, _retry_after_seconds, _limit, _holders resolved_limit = ( limit if limit is not None @@ -93,31 +117,96 @@ def reset_heavy_compute_gate( ) with _state_lock: _retry_after_seconds = resolved_retry + _limit = resolved_limit + _holders = [] _semaphore = threading.BoundedSemaphore(resolved_limit) def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]: - global _semaphore, _retry_after_seconds + global _semaphore, _retry_after_seconds, _limit 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( os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS ) - _semaphore = threading.BoundedSemaphore(limit) + _semaphore = threading.BoundedSemaphore(_limit) return _semaphore, _retry_after_seconds -def acquire_heavy_compute_slot(path: str) -> threading.BoundedSemaphore | None: +def _holder_view(now: float) -> list[dict]: + 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.""" if not is_heavy_compute_path(path): 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: - semaphore, retry_after = _ensure_locked() - if not semaphore.acquire(blocking=False): - raise HeavyComputeBusy(retry_after) - return semaphore + _ensure_locked() + return {"limit": _limit, "in_use": len(_holders)} -def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None: +def release_heavy_compute_slot(slot: _HeldSlot | None) -> None: if slot is not None: slot.release() diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 432467e5..79985813 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -122,13 +122,13 @@ except ModuleNotFoundError: # pragma: no cover - script execution path try: from scripts.api_heavy_compute_gate import ( HeavyComputeBusy, - acquire_heavy_compute_slot, + acquire_heavy_compute_slot_for_request, release_heavy_compute_slot, ) except ModuleNotFoundError: # pragma: no cover - script execution path from api_heavy_compute_gate import ( HeavyComputeBusy, - acquire_heavy_compute_slot, + acquire_heavy_compute_slot_for_request, release_heavy_compute_slot, ) @@ -3357,14 +3357,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM try: self._enforce_request_security(require_json=True) body = self._read_json_body() - # Chart-save warm builds today's packet on the same route. It must - # not take a heavy-compute slot: the cap is 2, and a question asked - # right after save would otherwise come back 429. - 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) + # Warm chart-save must not take a slot (BUG-1302). Chart overview + # gating is off unless JYOTISH_GATE_CHART_OVERVIEW=1. Both live in + # the gate module so this file only keeps the call. + compute_slot = acquire_heavy_compute_slot_for_request(path, body) if path == '/api/location/resolve': city = str(body.get('city') or '').strip() city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'} diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index e2f14143..982f1077 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -10762,7 +10762,25 @@ 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(): + cache_key = None try: import json import subprocess @@ -10772,6 +10790,9 @@ def _oracle_progress_snapshot(): root = Path(__file__).resolve().parents[1] 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: queue_path = Path(temp_dir) / 'oracle-queue.json' queue = subprocess.run( @@ -10797,7 +10818,7 @@ def _oracle_progress_snapshot(): raise RuntimeError(validation.stderr.strip() or validation.stdout.strip()) report = json.loads(validation.stdout) summary = report.get('summary', {}) - return { + result = { 'scope': 'external_oracle_evidence_validation', 'collection_queue': 'external_oracle_collection_queue', 'total_packets': summary.get('total_packets', 0), @@ -10809,7 +10830,7 @@ def _oracle_progress_snapshot(): 'boundary': report.get('boundary', 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.'), } except Exception as exc: - return { + result = { 'scope': 'external_oracle_evidence_validation', 'collection_queue': 'external_oracle_collection_queue', 'total_packets': 0, @@ -10820,6 +10841,9 @@ def _oracle_progress_snapshot(): 'promotion_rule': 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.', '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 diff --git a/scripts/research/api_chart_subprocess_ab.py b/scripts/research/api_chart_subprocess_ab.py new file mode 100644 index 00000000..fccc7279 --- /dev/null +++ b/scripts/research/api_chart_subprocess_ab.py @@ -0,0 +1,291 @@ +"""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() diff --git a/scripts/tajika.py b/scripts/tajika.py index 906b3495..4e6656c8 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -1501,7 +1501,28 @@ print(json.dumps({str(k): float(v) for k, v in dict(scores).items()})) 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]: + """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 = [ os.environ.get('JYOTISH_PYJHORA_PYTHON'), shutil.which('python3.14'), diff --git a/tests/fixtures/api-chart-subprocess-gate/README.txt b/tests/fixtures/api-chart-subprocess-gate/README.txt new file mode 100644 index 00000000..e5178a32 --- /dev/null +++ b/tests/fixtures/api-chart-subprocess-gate/README.txt @@ -0,0 +1,6 @@ +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. diff --git a/tests/fixtures/api-chart-subprocess-gate/annual-no-jhora.json b/tests/fixtures/api-chart-subprocess-gate/annual-no-jhora.json new file mode 100644 index 00000000..df78a16f --- /dev/null +++ b/tests/fixtures/api-chart-subprocess-gate/annual-no-jhora.json @@ -0,0 +1,41 @@ +{ + "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" + } +} diff --git a/tests/fixtures/api-chart-subprocess-gate/tajaka-no-jhora.json b/tests/fixtures/api-chart-subprocess-gate/tajaka-no-jhora.json new file mode 100644 index 00000000..2c1534ae --- /dev/null +++ b/tests/fixtures/api-chart-subprocess-gate/tajaka-no-jhora.json @@ -0,0 +1,12 @@ +{ + "node_mode": { + "requested": "mean", + "status": "unsupported_by_replay_adapter" + }, + "reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError", + "settings": { + "ayanamsa": "LAHIRI", + "node_mode": "mean" + }, + "status": "blocked" +} diff --git a/tests/test_api_chart_subprocess_gate.py b/tests/test_api_chart_subprocess_gate.py new file mode 100644 index 00000000..e8a97821 --- /dev/null +++ b/tests/test_api_chart_subprocess_gate.py @@ -0,0 +1,197 @@ +"""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() diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index e4300dbd..63633b2b 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -298,8 +298,11 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None: route = SYNASTRY_ROUTE.read_text(encoding="utf-8") for token in ( '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) 与 postPython("/api/chart", partnerPayload) + # 新值: 两次调用都展开对应 payload,并带 skip_vedastro_main_entry_overview: true + # 原因: 合盘改为同时排本人和对方两张盘,并关掉用不上的外部概览。断言仍锁住这两次 /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/synastry"', "moonLongitude(selfChart)",