Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbba127e71 | ||
|
|
32b238cf86 | ||
|
|
168e4dde19 | ||
|
|
2d41338897 | ||
|
|
360a667e4f | ||
|
|
904dfe9f79 | ||
|
|
9fae294532 | ||
|
|
a54f75e5e3 | ||
|
|
ef49257b73 | ||
|
|
9324253408 | ||
|
|
7175548531 | ||
|
|
0d73cd332b | ||
|
|
71f7fac3a1 | ||
|
|
454951de27 | ||
|
|
ab7f66be14 |
@@ -1,5 +1,10 @@
|
||||
# BLOCKED
|
||||
|
||||
## TASK-answer-effort-ab-20261013:答题步推理强度的真实 A/B(2026-10-15)
|
||||
|
||||
- **A/B 未跑:缺模型 key。** 本机没有 `DS_KEY` 或 `BACKTEST_MODEL_KEY`。供应商是否接受 `low` / `medium` / `high` / `max`、推理 token 和耗时有没有下降,都没测。不得把请求体拦截写成「A/B 通过」。
|
||||
- 开关默认不设,请求体与改前一致。默认值等有 key 的验收跑完再由产品拍板,本单不改部署。
|
||||
|
||||
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
|
||||
|
||||
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-15 — 内测容量:问得太密会停一下,人多时先排一小会儿
|
||||
|
||||
- 同一个人短时间问得太密、校正答得太密,或一小时里报告生成太多,会先停一下,这次不扣点。
|
||||
- 同时算的人多过机器能接的两路时,先排一小会儿。排上了继续;排不上会说人有点多,并把这一轮预留的点数退回。
|
||||
- Skill 版本不变(BUG-1355、BUG-1356)。
|
||||
|
||||
## 2026-10-15 — 合盘更快(待合入 staging)
|
||||
|
||||
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
|
||||
|
||||
## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging)
|
||||
|
||||
- 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。
|
||||
|
||||
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
|
||||
|
||||
- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ The API image `WORKDIR` is `/app` (`deploy/railway-api.Dockerfile`). Runtime fil
|
||||
|
||||
The volume is not a backup domain. Chart cache is a TTL'd compute cache (default 900s) and is cheaper to rebuild than to archive. Async / high-rigor job state is short-lived (default TTL 3600s) and is not a restore unit. Restart persistence is the only reason the volume exists. Staging encrypted backups remain PostgreSQL-only via `deploy/backup-staging-postgres.sh`; do not add `api_scratch` to that helper. Project-prefixed volume names are `jyotisha-staging_api_scratch` and `jyotisha-production_api_scratch`.
|
||||
|
||||
Heavy rectification scans and high-rigor workflows share a process-wide fail-fast concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). Saturated requests return HTTP 429 with `Retry-After` and are not queued. Health checks and other light routes do not take a slot.
|
||||
Heavy rectification scans and high-rigor workflows share a process-wide concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). A saturated request waits up to 8 seconds if fewer than 4 requests are already waiting, then returns HTTP 429 with `Retry-After`. Health checks and other light routes do not take a slot.
|
||||
|
||||
## DNS and Supabase Auth
|
||||
|
||||
|
||||
+104
-8
@@ -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 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||
|
||||
@@ -16174,6 +16174,7 @@
|
||||
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
||||
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
||||
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
||||
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
|
||||
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
||||
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
||||
- 复发自:无
|
||||
@@ -33401,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 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||
|
||||
@@ -34271,6 +34272,7 @@
|
||||
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
|
||||
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
|
||||
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
|
||||
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
|
||||
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
|
||||
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
|
||||
- 复发自:无
|
||||
@@ -36208,3 +36210,97 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。
|
||||
|
||||
## BUG-1352 | 星盘页上升行的星宿三项被排盘接口丢掉
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-03
|
||||
- 最近更新:2026-10-15
|
||||
- 影响面:星盘页本命盘和月亮盘的上升行(星宿、宿主、pada)
|
||||
- 用户现象:这两行的星宿、宿主、pada、状态都是「—」,像没算出来。九曜那几行是有的。
|
||||
- 触发条件:打开星盘页的本命盘或月亮盘。上升经度其实已经算好。
|
||||
- 根因:引擎给上升写了星宿三项,排盘接口在重组结果时只留了星座和度数,把这三项丢了。月亮盘用的是同一份上升。上次修复只测了引擎,前端样例又是事后补上这三项的,所以接口这一层一直没被发现。
|
||||
- 修复:接口重组上升时原样带上这三项;引擎没给时按同一套星宿算法补上。排盘缓存版本 6 改为 7,避免继续用没有这三项的旧结果。
|
||||
- 验证:虚构盘(1990-06-15 12:00 北京)走排盘处理函数,上升和 `modules.chart.ascendant` 的三项与星宿算法一致,为 Uttara Phalguni、1、Sun。本命表和月亮盘表都渲染出这三项。
|
||||
- 防复发:回归测试必须经过排盘处理函数,不能只测引擎,也不能手工往样例里补字段。
|
||||
- 相关记录:BUG-1201、BUG-1314
|
||||
- 复发自:BUG-1201(修复没有打通接口层)
|
||||
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。任务书写明 BUG-1351 已被另一单占用,本单从 BUG-1352 起。
|
||||
|
||||
## BUG-1353 | 星盘表居中列的表头靠左,当前大运卡片左边多一条线
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-15
|
||||
- 最近更新:2026-10-15
|
||||
- 影响面:星盘页行星表的表头,以及大运列表里当前这一段
|
||||
- 用户现象:分盘「庙旺」的表头靠左、格子里的字居中,对不齐。当前大运卡片左边有一条竖线。
|
||||
- 触发条件:打开星盘页的分盘表或大运列表。
|
||||
- 根因:居中只加在内容格子上,表头没有。八列表的写法是冻结的,不能改标记。大运当前段用内阴影画了左侧强调线,设计说明里没写,也没有测试盯着。
|
||||
- 修复:表头与内容一起居中。本命和月亮盘的八列表不改标记,用列选择器把速度、宿主、pada 的表头居中,宫位仍靠左。分盘的庙旺和同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。当前大运去掉左侧那条线,浅底和细边框留着。
|
||||
- 验证:样式合同锁定八列表对应表头居中、宫位列仍靠左,以及当前大运规则里没有 box-shadow。分盘、行运、Bhava 的渲染测试锁定表头带居中标记。
|
||||
- 防复发:上述样式合同和渲染测试。
|
||||
- 相关记录:BUG-1076、BUG-1352
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。
|
||||
|
||||
## BUG-1354 | 低强度推理设置写在嵌套字段里,供应商收不到
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||
- 来源:issue #65。任务书 `docs/tasks/TASK-answer-effort-ab-20261013.md`。请求体拦截见 `docs/testing/answer-effort-ab-20261013.md`。
|
||||
- 现象:代码里有一处把推理强度写成 `low`,但真正发出去的请求没有 `reasoning_effort` 这个字段。强度被包在 `thinking` 里面。
|
||||
- 触发条件:调用 `consultationSliceGenerationSettings`。产品代码里没有调用方,线上对话不受影响。
|
||||
- 根因:兼容通道只认供应商选项最外层的 `reasoningEffort`,并把它写成 `reasoning_effort`。嵌在 `thinking` 里的同名键会原样送出去,供应商不把它当强度。
|
||||
- 修复:本单不改这处形状,也不改它的两条断言。答题步的新开关写在最外层。要不要删掉或改掉这处旧形状,等产品拍板。
|
||||
- 验证:`frontend/tests/answer-effort-probe.mts --dry-run`。这处设置的请求体是 `thinking.reasoningEffort = "low"`,`reasoning_effort` 为空。新开关设成 `low` 时,答题步请求体有 `reasoning_effort: "low"`。
|
||||
- 防复发:新测试锁住「开关不设时请求体与改前一致」,并锁住新强度出现在最外层。
|
||||
- 相关记录:BUG-148、BUG-1142。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修。分支 `codex/answer-effort-ab-20261013` 只记录。原先写在 BUG-1351,该号已被数据层占用;按本文件规则保留较早的 BUG-1351,本条改为 BUG-1354,接在 BUG-1353 之后。
|
||||
|
||||
## BUG-1355 | 对话、校正回合和报告生成没有按用户限流
|
||||
|
||||
- 状态:mitigated
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-15
|
||||
- 来源:issue #76。任务书 `docs/tasks/TASK-beta-capacity-20261014.md`。
|
||||
- 现象:一个人可以连续打对话、校正回合或报告生成。报告只有每天 5 份的上限,没有按小时的上限。对话和校正没有按这个人计数。
|
||||
- 触发条件:已登录后调用 `POST /api/consult`、`POST /api/rectification/agent` 或 `POST /api/reports`。开案 `POST /api/rectification/cases/open` 不计数。
|
||||
- 根因:`requestRateLimits` 只覆盖地点搜索、时区、今日星语、星历、合盘和会话写入。重计算闸是全进程共享的,挡不住同一个人把模型调用打满。
|
||||
- 修复:对话 10 次/分钟且 120 次/小时,校正回合 5 次/分钟且 40 次/小时,报告生成另加 3 次/小时(每天 5 份仍在)。两档都还有余量才放行;拒绝时不写入任何一档,也不预留点数。响应是 429、`code: rate_limited` 和 `Retry-After`。
|
||||
- 验证:`frontend/tests/request-rate-limit.test.ts`;报告核心测试「hourly rate limit returns 429 and does not reserve」。
|
||||
- 防复发:上述测试锁住桶的边界、拒绝时不记入另一档,以及三条路由在预留点数之前判断。开案路由源码不得调用这组限流。
|
||||
- 相关记录:BUG-723(校正客户端已有的退避,本单不改)、BUG-1302(预热仍不占重计算名额)、BUG-1357(打回后补上的两条旧检查)。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/beta-capacity-20261014`。
|
||||
|
||||
## BUG-1356 | 重计算满了立刻拒绝,对话也不再试
|
||||
|
||||
- 状态:mitigated
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-15
|
||||
- 来源:issue #76。任务书 `docs/tasks/TASK-beta-capacity-20261014.md`。
|
||||
- 现象:同时只有 2 个重计算名额。第 3 个请求马上收到 429。普通对话把这次 429 当成这一轮失败,不按 `Retry-After` 再试。
|
||||
- 触发条件:已有 2 个请求占着 `/api/consultation_workflow` 或校正打分,又来一个对话取证。
|
||||
- 根因:闸门用非阻塞信号量,满了就抛 `HeavyComputeBusy`。对话侧把 HTTP 429 记成 `workflow_rate_limited` 后直接失败。
|
||||
- 修复:名额满时最多再等 8 秒,同时等待的人最多 4 个。等不到或等待的人已经满,仍抛 `HeavyComputeBusy`,`Retry-After` 仍是原来的秒数。对话对这种 429 最多再试 2 次,几次等待加起来不超过 15 秒。等待时进度行写「人有点多,排一下队」。仍失败则沿用原来的取消预留退点,只走一次,并告诉用户这一轮没排上、点数已退回。并发上限仍是 2。
|
||||
- 验证:`tests/test_api_heavy_compute_gate.py` 的排队拿到名额、等待者满立刻拒绝、超时仍 429;`frontend/tests/consultation-workflow-retry.test.ts`。
|
||||
- 防复发:上述测试。健康检查仍不进闸。校正客户端原有的退避(BUG-723)没有改。
|
||||
- 相关记录:BUG-717、BUG-723、BUG-1302、BUG-1345、BUG-1355、BUG-1357。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/beta-capacity-20261014`。
|
||||
|
||||
## BUG-1357 | 限流改完后两条旧检查没跟上
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-15
|
||||
- 最近更新:2026-10-15
|
||||
- 影响面:`frontend/tests/consultation-voice-contract.test.ts`;`frontend/tests/personal-report-api.test.ts` 与 `tests/test_repo_privacy_markers.py` 的行号白名单。没有用户界面变化。
|
||||
- 用户现象:同一个人问太密、校正太密、报告太多会先停一下,人多时会先排一小会儿。这些都对。全量检查多出 2 条失败,这一版上不了线。
|
||||
- 触发条件:验收方对 `a54f75e5` 跑前端全量和 Python 全量。
|
||||
- 根因:对话路由把 `runConsultationWorkflow(toolInput` 包进排队重试,源码合同仍要求 `await` 紧贴函数名。报告的小时限流测试插在文件中部,把隐私白名单按行号锁定的三行各下移 13 行。被保护的行为都还在,不是泄漏。上一轮只跑了定向测试,这两条不在那一组里。
|
||||
- 修复:正则仍锁定 `runConsultationWorkflow(toolInput`,不再要求 `await` 紧贴。小时限流测试整段挪到文件末尾,断言原文不变,`SAFE_LINES` 不改。业务代码不改。
|
||||
- 验证:`docs/tasks/PROGRESS-beta-capacity-fix-20261015.md`。语音合同、报告接口、镜像不引用测试共 75 通过、0 失败;隐私扫描 62 通过、0 失败;tsc 退出码 0;lint 0 错误、123 条原有警告。
|
||||
- 防复发:改这三处路由的语句前,在 `frontend/tests` 和 `tests` 里 grep 被改写的原文。新测试不要插在 `SAFE_LINES` 锁定行的上面。
|
||||
- 相关记录:BUG-933、BUG-1014、BUG-1355、BUG-1356
|
||||
- 复发自:BUG-933(源码合同没在同一轮跟上)
|
||||
- 修复版本:分支 `codex/beta-capacity-20261014`。
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 内测邀请码(用兑换码)
|
||||
|
||||
后台路径:`/admin/codes`(兑换码)。需要管理权限。不要把真实兑换码写进仓库、进度或聊天记录。完整码只在生成成功的那一次弹窗里出现,关掉就不能再看,列表里只剩掩码。
|
||||
|
||||
## 批量生成
|
||||
|
||||
1. 打开兑换码页,点「批量生成」。
|
||||
2. 「每个点数」是这一批每个码到账的点数。表单初始值是 10,可以改。内测每码给多少点由当次生成的人填写,产品以后可以改这个数。
|
||||
3. 「数量」一次 1 到 100 个。同一批的点数、到期时间、备注相同。
|
||||
4. 到期时间可以空着。备注用来区分这一批,例如内测第几批,不要写别人的个人资料。
|
||||
5. 生成后立刻把完整码交给对应的人。页面会提示关闭后无法再看。
|
||||
|
||||
## 查谁用了
|
||||
|
||||
列表有掩码、点数、状态、到期、备注、兑换账户、兑换时间、撤销时间。状态可以筛:可用、已过期、已兑换、已撤销。兑换账户和兑换时间就是谁用过。
|
||||
|
||||
## 作废
|
||||
|
||||
还没兑换、也没撤销的码,行上有「撤销」。撤销后不能再兑。已经兑换的不能从这里追回已经到账的点数。
|
||||
|
||||
支付在内测期间保持关闭。这页已经能批量生成、查看使用者和作废,本单不另加功能。
|
||||
@@ -0,0 +1,43 @@
|
||||
# PROGRESS · 答题步推理强度开关(2026-10-15)
|
||||
|
||||
- 分支:`codex/answer-effort-ab-20261013`
|
||||
- 实际基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`
|
||||
- 前置 `consult-prep-parallel` 已在 `origin/staging` 上(`git merge-base --is-ancestor` 退出码 0)
|
||||
- 默认行为不变。没有模型钥匙,A/B 未跑。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 新环境变量 `CONSULT_ANSWER_REASONING_EFFORT`。`low` / `medium` / `high` / `max` 才生效。空着或没设,请求体与改前一致。非法值 `console.warn` 一次,然后当作没设。
|
||||
2. `agentGenerationSettings` 增加 `providerReasoningEffort`。它写在供应商选项的最外层,兼容通道据此发出 `reasoning_effort`。原来的 `reasoningEffort` 仍嵌在 `thinking` 里,切片那两条断言不用改。
|
||||
3. 答题步才带这个字段。判据与答案时钟相同:计算结果已经在手(`answerReady`),或者公开日历从第一步就是答题(`answerFromStart`)。主循环第 0 步计算结果还没到,不带。重试是新的一轮,步号从 0 重新计,但计算结果已经在手,所以重试的第 0 步也带。续写仍是关闭思考,不带这个字段。
|
||||
4. Mastra 的 `prepareStep` 会按步合并 `providerOptions`(`mergeObjects`)。答题步返回的强度会并进这一步,不会整轮都带。本单按步生效,没有改成整轮。
|
||||
5. 生平回测脚本增加 `--cases figure:domain,…` 和 `--efforts unset,low,medium`。每行记下端到端秒数、第 0 / 1 步秒数和推理 token、输出 token、结束原因、是否空答、是否截断、正文字数。摘要记下当次强度。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 请求体拦截 | `node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run` | 退出码 0。数字见 `docs/testing/answer-effort-ab-20261013.md`。2026-10-15 打回后脚本从 `frontend/scripts/research/` 挪到 `frontend/tests/`,命令改成这一条。 |
|
||||
| 本单测试与任务书点名的四份 | `node node_modules/tsx/dist/cli.mjs --test` 后接 `answer-effort-switch-20261013`、`consultation-agentic-runtime`、`consultation-workflow-contract`、`consult-answer-clock-20261001`、`consult-latency-timing-20261005` | 114 pass,0 fail,0 cancelled |
|
||||
| 类型检查 | 在 `frontend` 下 `.\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning。探针里多出来的 1 条未使用变量已去掉,改过的文件再跑 eslint 是 0 |
|
||||
|
||||
开关不设时,两步请求体与改前保存的整份 JSON 逐字节一致。设 `low` 时只有答题步出现 `reasoning_effort: "low"`,第 0 步没有;续写仍是 `thinking: disabled` 且没有 `reasoning_effort`。
|
||||
|
||||
## 改既有断言
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `await streamWithOverflowRetry(agent)` | `await streamWithOverflowRetry(agent, generalStreamOptions)` | 公开日历从第一步就是答题。不传选项就没有 `prepareStep`,强度加不上去。开关不设时这些选项不改请求体。答案时钟的断言还在。 |
|
||||
|
||||
切片的嵌套形状和它的两条断言没有改。
|
||||
|
||||
## 需拍板
|
||||
|
||||
- 默认强度。本单不改。有钥匙的验收按任务书 T3 出数之后,再决定设不设、设哪一档。部署默认写在哪里也留到那次。
|
||||
- `consultationSliceGenerationSettings` 删掉还是改成最外层。它没有调用方。本单不改。见 BUG-1354(原先写成 BUG-1351,该号已被数据层占用)。
|
||||
- 若供应商不接受降档,对照组 D(答题步关思考)只供参考,采用与否另说。本机没跑。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
A/B 未跑:缺模型 key。没有 `DS_KEY`,也没有 `BACKTEST_MODEL_KEY`。
|
||||
@@ -0,0 +1,40 @@
|
||||
# PROGRESS · 答题推理强度开关修复(2026-10-15)
|
||||
|
||||
- 分支:`codex/answer-effort-ab-20261013`
|
||||
- 被打回的提交:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`
|
||||
- 修复单:`docs/tasks/TASK-answer-effort-ab-fix-20261015.md`
|
||||
- 已合并 `origin/staging` `71f7fac3a13f817f91755ff01b25b8706380ae19`,合并提交 `0d73cd332bf4efae2b8ec9389d5853384f4616f4`。没有改写已推送的历史。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 把 `frontend/scripts/research/answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`。文件名不是 `.test.ts`,也不是 `.test.tsx`,全量测试不会把它收成一条用例。
|
||||
2. 相对路径改过:源码从 `../src/...` 引入,夹具改成 `./fixtures/answer-effort-switch-20261013.json`。本单测试改为从同目录引入。已经保存的夹具文件没有重写。
|
||||
3. 命令同步进 `docs/tasks/PROGRESS-answer-effort-ab-20261013.md` 和 `docs/testing/answer-effort-ab-20261013.md`。
|
||||
4. 业务代码没改。开关默认值没改。任务书里留到拍板的默认强度,以及没人调用的旧嵌套设置,都没做。
|
||||
5. 合并时,本分支原来的调查记录占用了 BUG-1351,和数据层的 BUG-1351 撞号。保留 staging 上的 BUG-1351、BUG-1352、BUG-1353。这条调查记录改为 BUG-1354。BUG-1140 补记了这次同一形状的挪动。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 镜像守卫和本单测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test tests/runtime-sources-no-test-imports.test.ts tests/answer-effort-switch-20261013.test.ts` | 5 pass,0 fail,0 cancelled |
|
||||
| 请求体拦截 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run` | 退出码 0 |
|
||||
| 类型检查 | 在 `frontend` 下 `.\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 warning |
|
||||
| 隐私标记与 bug 记录格式 | 在工作树根 `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py tests/test_bug_history_workflow.py -q` | 64 pass,0 fail,退出码 0 |
|
||||
|
||||
守卫的两条都在上面 5 条里面:镜像目录不再引用测试目录,并且仍认得当初弄坏构建的那几种写法。
|
||||
|
||||
## prepareStep 会不会盖掉原有选项
|
||||
|
||||
不会整份换掉。开关打开时,答题步返回的供应商选项来自 `agentGenerationSettings`,Mastra 按步做深合并,并进这一步已有的选项。思考仍是打开。输出长度上限在基础流的模型设置里,这一步没有返回它,所以上限还在。缓存标记写在消息上,不在这份流选项里。因此没有往 `BLOCKED.md` 新写一条「被覆盖」。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 没有跑前端全量 `npm test`。这一侧的约定是不跑全量,留给验收。本机只跑了打回指出的那条守卫,以及本单测试。
|
||||
- 修复单写的 `git diff ab7f66be..<新提交> -- frontend/src` 不是空的。原因是按修复单先合并了最新 staging,staging 自己改过星盘页和数据层。本轮修复相对合并提交 `0d73cd33`,`frontend/src` 没有差异。业务代码和开关默认值没动。
|
||||
- A/B 未跑:缺模型 key。没有把请求体拦截写成对照已通过。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
A/B 未跑:缺模型 key。`BLOCKED.md` 里原有的这一条保留,没有新增。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 进度 · 内测容量(2026-10-15)
|
||||
|
||||
- 分支:`codex/beta-capacity-20261014`
|
||||
- 基线:开工时 `origin/staging` `ef49257b734678ff944a840076347baa02d0323c`
|
||||
- 任务书:`docs/tasks/TASK-beta-capacity-20261014.md`
|
||||
- 前置:`origin/codex/node-runtime-resources-20261013` 与 `origin/codex/rect-scoring-perf-20261013` 都是该 staging 的祖先(`merge-base --is-ancestor` 退出码 0)。限流文件里已有过期清理。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- T1:对话 10 次/分钟 + 120 次/小时,校正回合 5 次/分钟 + 40 次/小时,报告 3 次/小时(每天 5 份不变)。拒绝发生在预留点数之前,429 + `rate_limited` + `Retry-After`。开案路由不计数。
|
||||
- T2:重计算并发仍是 2。满了最多等 8 秒,同时等待最多 4 个;等不到或等待已满仍是 429,`Retry-After` 不变。对话对这种 429 最多再试 2 次,等待合计不超过 15 秒。进度行「人有点多,排一下队」,失败句「人有点多,这一轮没排上,点数已经退回。」退点仍走原来的取消预留,代理路径用已有的一次结算。
|
||||
- T3:后台已能批量生成(1–100 个、每码点数自填、初始 10)、按兑换账户查看、撤销未使用的码。说明在 `docs/operations/beta-invite-codes.md`。没有扩功能。
|
||||
- T4:脚本 `frontend/scripts/research/beta-load-smoke.mts`。不从 `frontend/tests` 导入。本机没有跑。
|
||||
- T5:BUG-1355、BUG-1356,CHANGELOG、VOICE、DESIGN、真机两条。
|
||||
|
||||
## 断言
|
||||
|
||||
旧测试仍要求「名额被占着就拒绝」,没有改拒绝码和 `Retry-After` 的期望值。
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tests/test_api_heavy_compute_gate.py` 立刻拒绝的夹具和单测 | `reset()` / `reset(limit=1, retry_after_seconds=3)` | 增加 `wait_seconds=0` | 默认改为最多等 8 秒。这些测试继续锁 429 和 `Retry-After`,排队另有新测试 |
|
||||
| `tests/test_api_chart_subprocess_gate.py` 两处占着名额再请求 | `reset(limit=1)` | `reset(limit=1, wait_seconds=0)` | 同上。仍锁同步排盘被拒绝、日志不出现请求正文 |
|
||||
|
||||
## 偏离与环境缺口
|
||||
|
||||
- T0:仓库文档里的目标规格,staging 与生产都是 2 vCPU / 4 GB,代码默认并发 2。这台 Windows 没有 staging 的登录壳,没有采到机器上的实际核数、内存和 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY`。不能把文档规格当成已经登主机核对过。
|
||||
- T4:没有 `STAGING_TEST_OTP`,没有对 staging 或生产发压测。改前、改后的并发数字都没有。服务器 CPU / 内存同样没有采样。脚本留给验收方在有测试验证码的环境重跑。20 并发档没有跑。
|
||||
- 旧版对话(不走代理运行时)会重试,但进度行要等回答流开始才有,所以那条路径等待时看不到「排一下队」。现在的对话走代理运行时,进度行会换字。任务书允许把排队文案收成只多等。
|
||||
- 没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
|
||||
- `deploy/README.md` 有一句仍写「满了不排队」。改成了最多等 8 秒、等待不超过 4 个,避免操作说明和代码相反。没有改部署配置。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果数字 |
|
||||
| --- | --- | --- |
|
||||
| Python 闸门 | `.venv\Scripts\python.exe -m pytest tests/test_api_heavy_compute_gate.py tests/test_api_chart_subprocess_gate.py -q` | 17 通过(8 + 9),0 失败 |
|
||||
| 前端相关测试 | `node node_modules/tsx/dist/cli.mjs --test tests/request-rate-limit.test.ts tests/consultation-workflow-retry.test.ts tests/personal-report-api.test.ts tests/runtime-sources-no-test-imports.test.ts` | 76 通过,0 失败 |
|
||||
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| 前端 lint | `npm run lint` | 0 错误,123 条原有警告 |
|
||||
| 压测 | 未执行 `frontend/scripts/research/beta-load-smoke.mts` | 本机没有 `STAGING_TEST_OTP`。没有改前/改后并发数字,没有服务器 CPU/内存 |
|
||||
|
||||
没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 进度 · 内测容量修复(2026-10-15,第 1 次打回)
|
||||
|
||||
- 分支:`codex/beta-capacity-20261014`
|
||||
- 被验 SHA:`a54f75e5e33775d5dc91f3058f5505a56b498bf2`
|
||||
- 修复单:`docs/tasks/TASK-beta-capacity-fix-20261015.md`
|
||||
- 原任务书:`docs/tasks/TASK-beta-capacity-20261014.md`
|
||||
- 开工时本分支最大 Bug 编号:BUG-1356。本轮新增 BUG-1357。BUG-1351、BUG-1352、BUG-1353 已在本分支文件里,没有重用。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 先把最新 `origin/staging` 合并进本分支(不改写已推送的 `a54f75e5`)。合并只带进修复单和任务索引的一行。
|
||||
- 语音合同:仍锁「工作流调用的入参是 `toolInput`」,以及用户轮不塞工具 JSON。不再要求 `await` 紧贴函数名。
|
||||
- 隐私白名单:采用修复单推荐的做法 a。把「hourly rate limit returns 429」整段挪到 `personal-report-api.test.ts` 末尾,断言原文不变。`SAFE_LINES` 不改。三行回到 832、860、890,且整行与白名单一致。
|
||||
- 业务代码不改。
|
||||
|
||||
## 断言
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `consultation-voice-contract.test.ts`「consult user turn no longer dumps tool JSON」 | `/await runConsultationWorkflow\(toolInput/` | `/runConsultationWorkflow\(toolInput/` | 调用包进 `retryOnConsultationRateLimit`,`await` 不再紧贴函数名。仍锁入参是 `toolInput` |
|
||||
| `personal-report-api.test.ts` 小时限流测试的位置 | 插在「429 at the daily limit」和「502 model_unavailable」之间 | 文件末尾,断言原文不变 | 插在中部让三行白名单各下移 13 行。挪走后行号复原,不改 `SAFE_LINES` |
|
||||
|
||||
## 源码文本再搜一遍
|
||||
|
||||
在 `frontend/tests` 和 `tests` 里核对三条路由被改写的语句:
|
||||
|
||||
- 只有语音合同把 `await runConsultationWorkflow(toolInput` 写成必须匹配。已按上表改。
|
||||
- `consultation-workflow-contract.test.ts` 是在代理分支里断言**不**出现 `await runConsultationWorkflow`,与这次包装无关,没改。
|
||||
- 「暂时无法生成解读」仍在非排队失败的分支里,`consult-first-frame-and-pacing-20260928.test.tsx` 继续对得上,没改。
|
||||
- `toNextResponse` 多了响应头,没有测试按旧函数签名做源码断言。
|
||||
- Python 侧没有按这三处被改写语句做源码文本断言。
|
||||
|
||||
## 偏离与环境缺口
|
||||
|
||||
- 没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
|
||||
- 并发压测 `frontend/scripts/research/beta-load-smoke.mts` 未跑。没有测试验证码,修复单也写明本机和执行侧都连不上测试服务器,不要登预发。没有改前/改后并发数字,没有服务器占用采样。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果数字 |
|
||||
| --- | --- | --- |
|
||||
| 前端相关测试 | `node node_modules/tsx/dist/cli.mjs --test tests/consultation-voice-contract.test.ts tests/runtime-sources-no-test-imports.test.ts tests/personal-report-api.test.ts` | 75 通过,0 失败,0 取消 |
|
||||
| 隐私扫描 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 62 通过,0 失败 |
|
||||
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| 前端 lint | `npm run lint` | 0 错误,123 条原有警告 |
|
||||
| 并发压测 | 未跑 `beta-load-smoke.mts` | 没有测试验证码,没有登预发 |
|
||||
@@ -0,0 +1,33 @@
|
||||
# PROGRESS · 星盘页上升行(issue #53 第二轮)
|
||||
|
||||
- 分支:`codex/chart-ascendant-row-20261015`
|
||||
- 基线:`origin/staging` `2b7112a6010acbcc0fec809b6b27e2375293dd5c`
|
||||
- 编号:本文件最大号是 BUG-1350。任务书说 BUG-1351 已被 #65 的分支占用,本单用 BUG-1352(上升星宿被接口丢掉,复发自 BUG-1201)和 BUG-1353(表头错位与大运左边线)。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- `/api/chart` 重组上升时带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`。引擎已经有就原样带上;没有才按同一套星宿算法补。`cache_schema_version` 6 → 7。
|
||||
- 上升行状态从「—」改为「不适用」。本命、月亮盘共用这一行。
|
||||
- 八列表的标记没改。速度、宿主、pada 的表头用列选择器居中,宫位仍靠左。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。
|
||||
- 当前大运去掉 `box-shadow`,浅底和细边框还在。
|
||||
|
||||
## 样例
|
||||
|
||||
`chart-view-golden.json` 里上升的三项本来就是 Uttara Phalguni、1、Sun,和这次接口真实返回一致,数值没有手改。`sourceCommand` 不再写「用 `_nakshatra_fields` 事后补上」,改为写明来自 `_compute_chart_sync`。
|
||||
|
||||
## 断言改动
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chart-varga-table.test.tsx` 上升行状态格 | `<td>—</td>` | `<td>不适用</td>` | 产品把横杠看成没数据 |
|
||||
| `test_api_scratch_retention.py` 缓存版本 | `6` | `7` | 旧缓存没有星宿三项,必须换键 |
|
||||
| 基线 html | 仍是改前的整张表 | 不改文件 | 测试用替换得到新表,原值留在基线里 |
|
||||
|
||||
## 自测
|
||||
|
||||
- `pytest tests/test_api_chart_ascendant_nakshatra.py tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py tests/test_api_scratch_retention.py -q`:34 通过,退出码 0。没有新增 `JyotishAPIHandler.__new__`。
|
||||
- 前端:`chart-varga-table`、`chart-moon-transit`、`chart-bhava`、`chart-page-mobile-width-20260929`、`runtime-sources-no-test-imports`。月亮盘那条第一次用整页去配「上升」,配到了图上的标记;改成只看月亮盘表格后通过。
|
||||
- `tsc --noEmit -p .`:0 个错误,退出码 0。
|
||||
- `npm run lint`:0 个错误、123 条原有警告,退出码 0。
|
||||
- `chart-page-view` 与 `chart-page-hook`:46 通过,退出码 0。
|
||||
- 真机清单沿用任务书里已有的 `docs/testing/chart-ascendant-row-20261015.md`。
|
||||
@@ -0,0 +1,48 @@
|
||||
# PROGRESS · 报告渲染函数拆分第一阶段
|
||||
|
||||
- 日期:2026-10-15
|
||||
- 分支:`codex/pl9-render-split-20261013`
|
||||
- 基线:`origin/staging` `32b238cf8619cfab4b1107059ec997c974eb0d13`
|
||||
- 范围:只做任务书第一阶段。不改 BUG_HISTORY(开工最大号 BUG-1357,本单不占号)、CHANGELOG、前端、API 服务器。
|
||||
|
||||
## 做了什么
|
||||
|
||||
把 `render_pl9_markdown` 里不依赖外层数据的 34 个嵌套函数原样搬到 `scripts/report_render/`。函数体只把 `planet_label_cn` 改成模块常量 `PLANET_LABEL_CN`。`SIGNS` / `SIGNS_CN` 按原字面量抄到 `text.py`,避免新包顶层导入引擎。
|
||||
|
||||
| 模块 | 函数 |
|
||||
| --- | --- |
|
||||
| envelope.py | `_unwrap` `_envelope_list` `_envelope_dict` `_bool_text` `_source_dict` `_has_executed_payload` |
|
||||
| text.py | `_humanize_reader_token` `_humanize_reader_group` `_humanize_tajika_candidate` `_humanize_tajika_motion` `_display_rashi_from_value` `_humanize_reader_summary_line` `_humanize_annual_highlight_line` `_humanize_annual_narrative_highlight` `_humanize_domain_title` `_workbook_readable_label` `_workbook_readable_sentence` `_workbook_reading_paragraph` `_md_cell` `_render_compact_payload` `_sign_text` `_value_text` `_compose_narrative_paragraph` `_append_unique_text` `_planet_list_text` |
|
||||
| sections_misc.py | `_render_boundary_packet_summary_line` `_status_from_report_pack_section` `_section_evidence_boundary` `_domi_pl9_current_gap_appendix` `_chart_planets_from_positions` `_kp_house_focus_text` `_high_value_theme_authority_sentence` `_kp_conflict_adjudication_sentence` `_prashna_exact_cusp_boundary_sentence` |
|
||||
|
||||
## 排除
|
||||
|
||||
- `_degree_text`:函数内定义两次,本阶段不搬。
|
||||
- `_sign_degree_text`、`_longitude_text`:任务书点名与度数簇一起排除。
|
||||
- `_profile_aware_benchmark_appendix`:要读引擎里的 `_blocked_planned_section` 和 `ROOT_DIR`。新包不能顶层导入引擎,否则成环,也过不了干净进程导入测试。
|
||||
- 依赖外层数据、或调用上述未搬函数的嵌套函数:留给下一阶段。
|
||||
|
||||
## 前后
|
||||
|
||||
| 项 | 搬之前 | 搬之后 |
|
||||
| --- | --- | --- |
|
||||
| `render_pl9_markdown` 行数 | 6917 | 6506 |
|
||||
| 嵌套函数 | 148 | 114 |
|
||||
|
||||
行数少了 411,嵌套函数少了 34。没有改既有断言。
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果数字 |
|
||||
| --- | --- | --- |
|
||||
| 未搬移,不屏蔽 | `report_render_split_diff.py` | 退出码 0,24 项 |
|
||||
| 未搬移,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 |
|
||||
| 搬移后,不屏蔽 | 同上 | 退出码 0,24 项 |
|
||||
| 搬移后,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 |
|
||||
| 指定 pytest + 隐私扫描 | `.venv\Scripts\python.exe -m pytest` 任务书列表与 `tests/test_repo_privacy_markers.py` | 退出码 0;236 收集,233 通过,3 跳过,0 失败 |
|
||||
|
||||
同一轮里,基线工作树和新代码渲染同一份 pickle,24 个 sha 全部相同。完整数据版中英 8 个 sha 在四轮里不变。屏蔽那轮曾有一次基线对基线不一致:夜间盘接口响应里年主重放一会儿是超时、一会儿是导入失败。这是屏蔽后的外部重放抖动,不是这次搬函数引入的。原样重跑后退出码 0。
|
||||
|
||||
## 偏离
|
||||
|
||||
任务书预期行数下降约 600 到 900。实际 411,因为上面三个排除项里有一份 142 行的附录。没有做第二阶段及以后。
|
||||
@@ -213,7 +213,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
|
||||
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
|
||||
| `TASK-chart-ascendant-row-20261015.md` | — | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段,BUG-1201 从未打通(golden 取自引擎内部);状态改「不适用」(推翻 DESIGN 原口径);居中列表头没居中;当前大运 inset 左线去掉。执行方 Grok | 待领取 | `codex/chart-ascendant-row-20261015` |
|
||||
| `TASK-chart-ascendant-row-20261015.md` | `PROGRESS-chart-ascendant-row-20261015.md` | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段(BUG-1352,复发自 BUG-1201);状态改「不适用」;居中列表头对齐;当前大运去掉左边线(BUG-1353) | 待验收 | `codex/chart-ascendant-row-20261015` |
|
||||
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
|
||||
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
|
||||
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
|
||||
@@ -339,7 +339,10 @@
|
||||
| `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` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
|
||||
| `TASK-beta-safety-net-fix-20261015.md` | — | **#75 产品选 A**:授权新增每日备份定时工作流,但每日备份不得改写 `recovery-baseline.env`(恢复点仍只手动创建);另记 `daily-backup-latest.env` | 待领取 | `codex/beta-safety-net-20261014` |
|
||||
| `TASK-beta-safety-net-fix2-20261015.md` | — | **#75 修复单 2**:生产侧全过;飞书测试 env 文件权限 0644 在 Linux 上失败(会挡发布门禁);新测试不在 quick 门禁;磁盘 ≥70% 后备份静默停摆;监控脚本缺 sudo 与 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`) | — |
|
||||
@@ -485,8 +488,8 @@
|
||||
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
|
||||
| `TASK-api-chart-subprocess-gate-20261013.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 第 1 次打回,见修复单 | `codex/api-chart-subprocess-gate-20261013` |
|
||||
| `TASK-api-chart-subprocess-gate-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` | — | 答题步推理强度开关 + 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-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` |
|
||||
|
||||
@@ -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 次打回。
|
||||
@@ -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. 让步顺序
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,45 @@
|
||||
# TASK · 内测安全网修复单 2(issue #75)
|
||||
|
||||
- 日期:2026-10-15
|
||||
- 前序:`TASK-beta-safety-net-20261014.md`、`TASK-beta-safety-net-fix-20261015.md`(产品选 A)
|
||||
- 被验 SHA:`5c0d895391b5d0806c98de669a2e9cdcd61e3335`(`codex/beta-safety-net-20261014`)
|
||||
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
|
||||
- 红线不变:仍不改已有四个生产工作流与 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;不点生产按钮
|
||||
|
||||
## 1. 验收结论:面向生产的部分全部过;一条阻断 + 三条要一起修
|
||||
|
||||
已确认成立:每日工作流只从 main 跑、与恢复点工作流同一把锁 / 同样的密钥处理、不回显密钥;每日模式**任何路径都不写** `recovery-baseline.env`(只有手动分支写),失败时什么状态都不写;`daily-backup-latest.env` 只有 4 个键;发布 / 迁移 / 回退不读它;手动恢复点(含 `RECOVERY_FORMAT=2` 与权限比对)不变;清理脚本只认 `^production-daily-\d{8}T\d{6}Z\.dump\.enc$` 的普通文件,碰不到 `production-pre-migration-*`;错误分类只外发校验过的 errorCode / 路由 / UUID,不发用户内容;compose 只加了 6 个服务一致的 `logging`。
|
||||
|
||||
### B1(阻断)· 测试在 Linux 上失败
|
||||
|
||||
`tests/test_beta_safety_net.py` 约 616–634 行 `test_feishu_payload_keeps_only_allowed_fields`:约 76–77 行用 `write_text` 写 `monitor.env`,默认权限 0644;`deploy/monitor/notify-feishu.sh` 约 18–23 行在 Linux 上拒绝非 0600 的环境文件 → 退出 1。macOS / Windows 上跳过了这项检查所以没发现。`release-quality-gate.yml` 在 Linux 上跑全树 pytest,提升 main 会被它挡住。
|
||||
- 修:测试里写完后 `os.chmod(env_file, 0o600)`。脚本里的检查是对的,不改。
|
||||
|
||||
### N1 · 新测试不进 staging 门禁
|
||||
|
||||
`scripts/run_quality_gate.py` 的 quick 清单(约 55–90 行)没有 `tests/test_beta_safety_net.py`,合同只在发布时才跑——B1 就是这样漏过去的。把它加进 quick 清单。
|
||||
|
||||
### N2 · 磁盘 ≥70% 后备份静默停摆
|
||||
|
||||
`run-production-recovery.sh` 约 149–153 行磁盘 ≥70% 拒绝;`backup-production-daily.yml` 约 145–146 行只在备份成功后才清理旧文件 → 一旦到 70%,旧文件永不清理、每天都失败;磁盘告警 80% 才响,也没人检查 `daily-backup-latest.env` 有多旧。
|
||||
- 修:清理放在备份**之前**(或 `if: always()`);`healthcheck-alert.sh` 增加「最近一次每日备份超过 36 小时」告警(读 `daily-backup-latest.env` 的 `verified_at`)。加测试。
|
||||
|
||||
### N3 · 两个监控脚本在生产主机上大概率跑不起来
|
||||
|
||||
`deploy/monitor/error-digest.sh` 约 54–59 行、`deploy/find-request-log.sh` 约 300–307 行直接调 `docker`,其它生产脚本与恢复演练都用 `sudo -n docker`;也没导出 `DATABASE_ENV_FILE`,`docker-compose.postgres.yml` 会回落到不存在的 `../.env.staging.database`,Compose 拒绝加载。错误汇总由 cron 每 5 分钟跑,会每次失败、什么都不发。
|
||||
- 修:统一 `sudo -n docker`,显式传入生产的 compose 文件与 env 文件路径(与 `run-production-deploy.sh` 同一写法);测试断言命令行形状。
|
||||
|
||||
## 2. 顺手修(不阻断,一并做)
|
||||
|
||||
- `notify-feishu.sh` 约 96–101 行:`curl` 加 `--fail`,并检查飞书返回体里的 `code == 0`;失败时不记成「已发送」。
|
||||
- `error-digest.sh`:同一类告警 30 分钟内不重复发。
|
||||
- `find-request-log.sh`:真实路径按时间戳排序(去掉 compose 前缀后再排),与 README 说法一致。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- Linux:`python3 -m pytest tests/test_beta_safety_net.py -q` 全绿;Claude 全量 pytest、前端全量无新增;`bash -n` 全过;quick 门禁包含新测试。
|
||||
- 交回前本机跑上面那条 pytest(Windows 上请用 WSL 或在测试里显式断言 0600 已生效)。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
B1、N1、N3 不可让;N2 的「过旧告警」可写理由后让;第 2 节可让。
|
||||
@@ -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、人工四项都没做。默认值维持不设。
|
||||
@@ -0,0 +1,6 @@
|
||||
# 内测容量 · 真机两条(2026-10-14)
|
||||
|
||||
用测试账号。不要把出生资料、姓名、邮箱写进记录。
|
||||
|
||||
1. 快速连发触发限流。登录后在一分钟里连续发送超过 10 次普通对话。预期:出现「这一会儿问得有点密,先停一下。」这一轮不扣点。等提示里的时间过去再发一次,可以正常问。
|
||||
2. 排队提示出现与消失。需要两个人同时占着计算(或用压测脚本打满两个名额)时,第三个人发普通对话。预期:进度行先出现「人有点多,排一下队」,排上之后这句消失,回到正在计算。若一直排不上,出现「人有点多,这一轮没排上,点数已经退回。」余额与发送前一致。
|
||||
+4
-4
@@ -907,7 +907,7 @@ or user IDs.
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
**普通对话发出即有反馈(2026-09-28,TASK-consult-first-frame-and-pacing,BUG-1074)。** 按下发送的那一帧,回答行就带着步骤栏,唯一一行是排队行「收到,正在看你的问题…」(`QUEUED_TIMELINE_ROW`,行内 `InlineSpinner` + shimmer,summary 写「正在分析」)。服务端流的第一个字节也是这句(`activity` 事件,phase `received`),所以那一帧到达时行不换字。不再有「分类结果回来之前整行静默」:只有服务端判定为寒暄的回复(`run.completed` 带 `responseKind: "smalltalk"`)才把步骤栏收掉、只留一句话,寒暄前会闪约一秒这一行,属于流式生成中的正常形态。首事件之后的行照旧由服务端事件推动;不新增第二种 live 标记,不加 spinner 面板或骨架。
|
||||
**普通对话发出即有反馈(2026-09-28,TASK-consult-first-frame-and-pacing,BUG-1074)。** 按下发送的那一帧,回答行就带着步骤栏,唯一一行是排队行「收到,正在看你的问题…」(`QUEUED_TIMELINE_ROW`,行内 `InlineSpinner` + shimmer,summary 写「正在分析」)。服务端流的第一个字节也是这句(`activity` 事件,phase `received`),所以那一帧到达时行不换字。不再有「分类结果回来之前整行静默」:只有服务端判定为寒暄的回复(`run.completed` 带 `responseKind: "smalltalk"`)才把步骤栏收掉、只留一句话,寒暄前会闪约一秒这一行,属于流式生成中的正常形态。首事件之后的行照旧由服务端事件推动;不新增第二种 live 标记,不加 spinner 面板或骨架。重计算名额满了时,计算那一行先换成「人有点多,排一下队」(`activity` phase `compute-queue`,仍是原来的 live 行),排上之后回到「正在计算本命盘…」。排不上则整轮失败,文案是「人有点多,这一轮没排上,点数已经退回。」
|
||||
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后若还有前事核对,服务端写一条自己的采用消息(「已按 HH:MM 排盘…」),核对题的四个选项挂在这条消息下,进行中那一行由它替换,不挂到上一题的回复下(BUG-1146)。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
|
||||
|
||||
@@ -1178,7 +1178,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
| pada | 1–4,居中 |
|
||||
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
|
||||
|
||||
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。
|
||||
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「不适用」(2026-10-15,与分盘表、报告同一口径;不再写「—」)。数字列 `tabular-nums`,符号列与宿主列居中。
|
||||
|
||||
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
|
||||
|
||||
@@ -1192,7 +1192,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
|
||||
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
|
||||
|
||||
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。
|
||||
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化:表头与内容同为居中。本命/月亮八列 markup 冻结,速度、宿主、pada 的表头用列选择器居中,不改 markup,也不把宫位那一列居中。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头带 `chart-page-planet-mid`。不能全局覆盖 `chart-page-planet-mid` 去改宫位列。
|
||||
|
||||
### 三套坐标系
|
||||
|
||||
@@ -1214,7 +1214,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
|
||||
|
||||
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。
|
||||
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。
|
||||
|
||||
### 西洋盘 Tab(2026-09-25)
|
||||
|
||||
|
||||
@@ -480,3 +480,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 不写健康诊断、投资建议、法律意见;不写「一定」「必然」「注定」。
|
||||
- 出生时间没确认时不用宫位,只按月亮所在星座写,caution 固定为「出生时间尚未确认,这里只看大方向,不依赖具体分钟。」
|
||||
|
||||
## 人多与问得太密(2026-10-15)
|
||||
|
||||
不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Staging-only smoke for 10 or 20 concurrent consultations.
|
||||
*
|
||||
* Not imported by frontend/src. Reads STAGING_TEST_OTP from the environment
|
||||
* and never prints it. Refuses a production host. Does not sample server
|
||||
* CPU or memory; that needs a staging shell this process does not have.
|
||||
*
|
||||
* node node_modules/tsx/dist/cli.mjs scripts/research/beta-load-smoke.mts --users 10
|
||||
*/
|
||||
const usersArg = process.argv.find((arg) => arg.startsWith("--users="));
|
||||
const usersFlag = process.argv.indexOf("--users");
|
||||
const requestedUsers = Number(usersArg ? usersArg.slice("--users=".length) : process.argv[usersFlag + 1]);
|
||||
const baseUrl = (process.env.STAGING_BASE_URL || "https://staging.jyotisha.chat").replace(/\/$/, "");
|
||||
const otp = process.env.STAGING_TEST_OTP || "";
|
||||
const emailDomain = process.env.STAGING_TEST_EMAIL_DOMAIN || "example.test";
|
||||
|
||||
function refuse(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(baseUrl).host;
|
||||
} catch {
|
||||
refuse("STAGING_BASE_URL is not a URL");
|
||||
}
|
||||
if (host === "jyotisha.chat" || host === "www.jyotisha.chat" || host === "admin.jyotisha.chat") {
|
||||
refuse("refusing production host");
|
||||
}
|
||||
if (!/^\d{6}$/.test(otp)) refuse("STAGING_TEST_OTP is not set");
|
||||
if (requestedUsers !== 10 && requestedUsers !== 20) refuse("pass --users 10 or --users 20");
|
||||
if (!/^[a-z0-9.-]+$/.test(emailDomain)) refuse("STAGING_TEST_EMAIL_DOMAIN is not a plain domain");
|
||||
|
||||
type Sample = {
|
||||
kind: "consult" | "rectification";
|
||||
status: number;
|
||||
firstByteMs: number | null;
|
||||
totalMs: number;
|
||||
failed: boolean;
|
||||
http429: boolean;
|
||||
http5xx: boolean;
|
||||
};
|
||||
|
||||
const samples: Sample[] = [];
|
||||
const CONSULT_QUEUE_FAILED = "人有点多,这一轮没排上,点数已经退回。";
|
||||
|
||||
function percentile(values: number[], ratio: number): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
|
||||
return sorted[index] ?? null;
|
||||
}
|
||||
|
||||
async function request(jar: Map<string, string>, path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (jar.size > 0) headers.set("cookie", [...jar].map(([key, value]) => `${key}=${value}`).join("; "));
|
||||
const response = await fetch(`${baseUrl}${path}`, { ...init, headers, redirect: "manual" });
|
||||
const setCookies = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
||||
for (const line of setCookies) {
|
||||
const pair = line.split(";")[0] ?? "";
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function timed(jar: Map<string, string>, path: string, body: unknown, kind: Sample["kind"]): Promise<void> {
|
||||
const started = Date.now();
|
||||
let status = 0;
|
||||
let firstByteMs: number | null = null;
|
||||
let failed = false;
|
||||
try {
|
||||
const response = await request(jar, path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
status = response.status;
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (firstByteMs === null && (chunk.value || chunk.done)) firstByteMs = Date.now() - started;
|
||||
if (chunk.value) text += new TextDecoder().decode(chunk.value);
|
||||
if (chunk.done) break;
|
||||
}
|
||||
failed = text.includes('"type":"run.failed"') || text.includes(CONSULT_QUEUE_FAILED);
|
||||
} else {
|
||||
firstByteMs = Date.now() - started;
|
||||
failed = status >= 400;
|
||||
}
|
||||
} catch {
|
||||
status = 0;
|
||||
failed = true;
|
||||
}
|
||||
samples.push({
|
||||
kind,
|
||||
status,
|
||||
firstByteMs,
|
||||
totalMs: Date.now() - started,
|
||||
failed,
|
||||
http429: status === 429,
|
||||
http5xx: status >= 500 || status === 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function login(index: number): Promise<Map<string, string> | null> {
|
||||
const jar = new Map<string, string>();
|
||||
const email = `capacity-smoke-${index}@${emailDomain}`;
|
||||
const sent = await request(jar, "/api/auth/email-otp/send-verification-otp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, type: "sign-in" }),
|
||||
});
|
||||
if (!sent.ok) return null;
|
||||
const signed = await request(jar, "/api/auth/sign-in/email-otp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, otp }),
|
||||
});
|
||||
if (!signed.ok || jar.size === 0) return null;
|
||||
return jar;
|
||||
}
|
||||
|
||||
async function prepareAccount(jar: Map<string, string>): Promise<boolean> {
|
||||
const saved = await request(jar, "/api/account", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "容量冒烟",
|
||||
birth_date: "1990-06-15",
|
||||
reported_birth_time: "12:00",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 0,
|
||||
uncertainty_after_minutes: 0,
|
||||
country_code: "CN",
|
||||
province_code: "110000",
|
||||
city_code: "110000",
|
||||
district_code: "110101",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezone_offset: 8,
|
||||
}),
|
||||
});
|
||||
return saved.ok;
|
||||
}
|
||||
|
||||
async function oneUser(index: number): Promise<string[]> {
|
||||
const deleted: string[] = [];
|
||||
const jar = await login(index);
|
||||
if (!jar) {
|
||||
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
|
||||
return deleted;
|
||||
}
|
||||
if (!await prepareAccount(jar)) {
|
||||
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
|
||||
return deleted;
|
||||
}
|
||||
const models = await request(jar, "/api/models");
|
||||
const catalog = models.ok ? await models.json().catch(() => null) as { defaultModelId?: string } | null : null;
|
||||
const modelId = catalog?.defaultModelId;
|
||||
if (!modelId) {
|
||||
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
|
||||
return deleted;
|
||||
}
|
||||
const sessionId = crypto.randomUUID();
|
||||
const created = await request(jar, "/api/sessions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: sessionId,
|
||||
title: "容量冒烟",
|
||||
theme: "general",
|
||||
model_id: modelId,
|
||||
messages: [],
|
||||
session_type: "consultation",
|
||||
rectification_case_id: null,
|
||||
chart_profile_role: "self",
|
||||
}),
|
||||
});
|
||||
if (!created.ok) {
|
||||
samples.push({ kind: "consult", status: created.status, firstByteMs: null, totalMs: 0, failed: true, http429: created.status === 429, http5xx: created.status >= 500 });
|
||||
return deleted;
|
||||
}
|
||||
const chart = {
|
||||
year: 1990,
|
||||
month: 6,
|
||||
day: 15,
|
||||
hour: 12,
|
||||
minute: 0,
|
||||
lat: 39.9042,
|
||||
lon: 116.4074,
|
||||
tz: 8,
|
||||
city: "北京",
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
await timed(jar, "/api/consult", {
|
||||
...chart,
|
||||
requestId: crypto.randomUUID(),
|
||||
sessionId,
|
||||
modelId,
|
||||
consultationMode: "verified_chart",
|
||||
theme: "general",
|
||||
question: "今年工作整体顺不顺?",
|
||||
history: [],
|
||||
}, "consult");
|
||||
await timed(jar, "/api/consult", {
|
||||
...chart,
|
||||
requestId: crypto.randomUUID(),
|
||||
sessionId,
|
||||
modelId,
|
||||
consultationMode: "verified_chart",
|
||||
theme: "general",
|
||||
question: "那接下来几个月呢?",
|
||||
history: [],
|
||||
}, "consult");
|
||||
if (index < 3) {
|
||||
const opened = await request(jar, "/api/rectification/cases/open", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ intent: "homepage", requestId: crypto.randomUUID() }),
|
||||
});
|
||||
const openedBody = opened.ok ? await opened.json().catch(() => null) as { caseId?: string; sessionId?: string } | null : null;
|
||||
if (openedBody?.caseId && openedBody.sessionId) {
|
||||
for (const message of ["我小时候搬家过一次。", "那次大概是上小学之前。"]) {
|
||||
await timed(jar, "/api/rectification/agent", {
|
||||
caseId: openedBody.caseId,
|
||||
sessionId: openedBody.sessionId,
|
||||
requestId: crypto.randomUUID(),
|
||||
action: "message",
|
||||
message,
|
||||
}, "rectification");
|
||||
}
|
||||
deleted.push(openedBody.sessionId);
|
||||
} else {
|
||||
samples.push({
|
||||
kind: "rectification",
|
||||
status: opened.status,
|
||||
firstByteMs: null,
|
||||
totalMs: 0,
|
||||
failed: true,
|
||||
http429: opened.status === 429,
|
||||
http5xx: opened.status >= 500 || opened.status === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const removed = await request(jar, `/api/sessions/${sessionId}`, { method: "DELETE" });
|
||||
if (removed.ok) deleted.push(sessionId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const cleaned = (await Promise.all(Array.from({ length: requestedUsers }, (_, index) => oneUser(index)))).flat();
|
||||
const consults = samples.filter((sample) => sample.kind === "consult");
|
||||
const finalFailures = consults.filter((sample) => sample.failed || sample.http429 || sample.http5xx);
|
||||
const firstBytes = consults.map((sample) => sample.firstByteMs).filter((value): value is number => value !== null);
|
||||
const totals = consults.map((sample) => sample.totalMs);
|
||||
const summary = {
|
||||
users: requestedUsers,
|
||||
host,
|
||||
requests: samples.length,
|
||||
consults: consults.length,
|
||||
http429: samples.filter((sample) => sample.http429).length,
|
||||
http5xx: samples.filter((sample) => sample.http5xx).length,
|
||||
finalFailures: finalFailures.length,
|
||||
finalFailureRatio: consults.length === 0 ? null : Number((finalFailures.length / consults.length).toFixed(4)),
|
||||
firstByteP95Ms: percentile(firstBytes, 0.95),
|
||||
totalP95Ms: percentile(totals, 0.95),
|
||||
sessionsDeleted: cleaned.length,
|
||||
serverSample: "not taken; this process has no staging docker stats",
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
console.log(JSON.stringify(summary));
|
||||
@@ -27,6 +27,7 @@
|
||||
* Usage (from frontend/, Node 22):
|
||||
* DS_KEY=... ./node_modules/.bin/tsx scripts/research/consult-biography-backtest.mts \
|
||||
* [--out <dir>] [--repeats 2] [--figures a,b] [--domains parents,marriage,health,career] \
|
||||
* [--cases steve_jobs:career,barack_obama:parents] [--efforts unset,low,medium] \
|
||||
* [--model deepseek-flash] [--base-url https://api.deepseek.com] [--concurrency 4] [--dump-cards]
|
||||
* [--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<string, string>,
|
||||
cases: value("cases")?.split(",").map((item) => item.trim()).filter(Boolean) ?? null,
|
||||
efforts: value("efforts")?.split(",").map((item) => item.trim()).filter(Boolean) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,7 +273,15 @@ function model(args: ReturnType<typeof parseArgs>, apiKey: string): ResolvedLang
|
||||
|
||||
/** A follow-up turn in the same session: the earlier question and answer are the history. */
|
||||
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<unknown>[] = [];
|
||||
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<typeof deterministicChecks> };
|
||||
type Row = {
|
||||
figure: string;
|
||||
domain: string;
|
||||
run: number;
|
||||
chars: number;
|
||||
seconds: number;
|
||||
checks: ReturnType<typeof deterministicChecks>;
|
||||
empty?: boolean;
|
||||
truncated?: boolean;
|
||||
finish?: string | null;
|
||||
steps?: { seconds: number | null; reasoningTokens: number | null; outputTokens: number | null }[];
|
||||
};
|
||||
|
||||
function writeSummary(out: string, summary: Json & { rows: Row[] }, modelId: string) {
|
||||
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<ReturnType<typeof runJob>>;
|
||||
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,
|
||||
|
||||
@@ -75,6 +75,8 @@ import {
|
||||
createConsultationRunClock,
|
||||
consultationContinueGenerationSettings,
|
||||
consultationGenerationSettings,
|
||||
consultationGeneralPrepareStep,
|
||||
consultAnswerEffortProviderOptions,
|
||||
consultationNatalPrepareStep,
|
||||
consultationWindowPrepareStep,
|
||||
createConsultationAgentContext,
|
||||
@@ -127,6 +129,12 @@ import {
|
||||
import { persistGuardedSessionTitle } from "@/lib/session-title-guard";
|
||||
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
|
||||
import { z } from "zod";
|
||||
import { CONSULTATION_QUEUE_FAILED_MESSAGE, isConsultationRateLimitFailure, retryOnConsultationRateLimit } from "@/lib/consultation-workflow-retry";
|
||||
import {
|
||||
consumeUserRateLimitSet,
|
||||
userRateLimitDeniedResponse,
|
||||
userRateLimitMessages,
|
||||
} from "@/lib/request-rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 480;
|
||||
@@ -365,6 +373,11 @@ export async function POST(request: Request) {
|
||||
const currentDate = chinaCalendarDate(requestTime);
|
||||
|
||||
const userId = user.id;
|
||||
const limited = consumeUserRateLimitSet(["consultMinute", "consultHour"], userId);
|
||||
if (!limited.ok) {
|
||||
const denied = userRateLimitDeniedResponse(limited.retryAfterSeconds, userRateLimitMessages.consult);
|
||||
return NextResponse.json(denied.body, { status: denied.status, headers: denied.headers });
|
||||
}
|
||||
const requestId = parsed.data.requestId;
|
||||
const sessionId = parsed.data.sessionId;
|
||||
const consultationTheme = parsed.data.theme;
|
||||
@@ -1288,6 +1301,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 +1310,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 +1329,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<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
|
||||
},
|
||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions,
|
||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions | typeof generalStreamOptions = streamOptions,
|
||||
) {
|
||||
try {
|
||||
const result = await agent.stream(baseMessages, options);
|
||||
@@ -1369,14 +1393,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 +1496,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;
|
||||
};
|
||||
@@ -1769,9 +1797,11 @@ export async function POST(request: Request) {
|
||||
theme: parsed.data.theme,
|
||||
});
|
||||
const workflowContext = applyBirthTimeModeToWorkflowContext(
|
||||
await runConsultationWorkflow(toolInput, {
|
||||
foreground: true,
|
||||
plan: prepared.preReserveResult,
|
||||
await retryOnConsultationRateLimit({
|
||||
run: () => runConsultationWorkflow(toolInput, {
|
||||
foreground: true,
|
||||
plan: prepared.preReserveResult,
|
||||
}),
|
||||
}),
|
||||
consultationMode,
|
||||
{ birthTimeSource: prepared.serverChart.truth.birthTimeSource },
|
||||
@@ -1877,12 +1907,15 @@ export async function POST(request: Request) {
|
||||
console.error(
|
||||
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
|
||||
);
|
||||
const rateLimited = isConsultationRateLimitFailure(error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "暂时无法生成解读",
|
||||
message: "咨询服务暂时不可用,请稍后再试。",
|
||||
recovery: "稍后重试,或换一个模型继续。",
|
||||
},
|
||||
rateLimited
|
||||
? { error: CONSULTATION_QUEUE_FAILED_MESSAGE, message: CONSULTATION_QUEUE_FAILED_MESSAGE }
|
||||
: {
|
||||
error: "暂时无法生成解读",
|
||||
message: "咨询服务暂时不可用,请稍后再试。",
|
||||
recovery: "稍后重试,或换一个模型继续。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
replyToDeclaredBirthWindow,
|
||||
} from "@/lib/rectification-agentic/v9/agent-route-typed-message";
|
||||
import { respondWithRectificationAgentTurn } from "@/lib/rectification-agentic/v9/agent-route-agent-turn";
|
||||
import {
|
||||
consumeUserRateLimitSet,
|
||||
userRateLimitDeniedResponse,
|
||||
userRateLimitMessages,
|
||||
} from "@/lib/request-rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 240;
|
||||
@@ -66,6 +71,20 @@ export async function POST(request: Request) {
|
||||
const frozen = await refuseWhenAccountDeletionPending(userId);
|
||||
if (frozen) return frozen;
|
||||
|
||||
// Opening a case is POST /api/rectification/cases/open and is not counted.
|
||||
// Every turn on this route is, and the check is before any credit reserve.
|
||||
const limited = consumeUserRateLimitSet(
|
||||
["rectificationTurnMinute", "rectificationTurnHour"],
|
||||
userId,
|
||||
);
|
||||
if (!limited.ok) {
|
||||
const denied = userRateLimitDeniedResponse(
|
||||
limited.retryAfterSeconds,
|
||||
userRateLimitMessages.rectificationTurn,
|
||||
);
|
||||
return NextResponse.json(denied.body, { status: denied.status, headers: denied.headers });
|
||||
}
|
||||
|
||||
if (action === "opening") {
|
||||
const consistency = await finalizeSuccessfulTurnExit({
|
||||
accounting: accounting as never, userId, caseId, action, segmentOpening: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-sna
|
||||
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
|
||||
import { stampReportBirthFingerprint } from "@/lib/report-birth-binding";
|
||||
import { runConsultationWorkflow } from "@/mastra";
|
||||
import { consumeUserRateLimitSet, userRateLimitMessages } from "@/lib/request-rate-limit";
|
||||
import { createPersonalReportAgent } from "@/mastra/personal-report";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import {
|
||||
@@ -68,8 +69,8 @@ function sanitizedErrorCode(error: unknown): string {
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
|
||||
return NextResponse.json(response.body, { status: response.status });
|
||||
function toNextResponse(response: { status: number; body: Record<string, unknown>; headers?: Readonly<Record<string, string>> }) {
|
||||
return NextResponse.json(response.body, { status: response.status, headers: response.headers });
|
||||
}
|
||||
|
||||
function listReportView(
|
||||
@@ -308,6 +309,13 @@ export async function POST(request: Request) {
|
||||
},
|
||||
persistence,
|
||||
model: defaultModel,
|
||||
consumeReportRateLimit: userId
|
||||
? () => {
|
||||
const decision = consumeUserRateLimitSet(["reportGenerateHour"], userId);
|
||||
if (decision.ok) return decision;
|
||||
return { ...decision, message: userRateLimitMessages.reportGenerate };
|
||||
}
|
||||
: undefined,
|
||||
billing: {
|
||||
reserve: async ({ userId: billingUserId, requestId, modelId }) => {
|
||||
try {
|
||||
|
||||
@@ -5655,11 +5655,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-planet-glyph.is-node,
|
||||
.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); }
|
||||
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
|
||||
.chart-page-planet-table th.chart-page-planet-mid,
|
||||
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
|
||||
/* The natal/Moon eight-column markup stays frozen; only its house column changes. */
|
||||
/* The natal/Moon eight-column markup stays frozen; only its house column changes.
|
||||
Speed, nakshatra lord and pada stay centered, including their headers. */
|
||||
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) {
|
||||
text-align: left;
|
||||
}
|
||||
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(3),
|
||||
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(6),
|
||||
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(7) {
|
||||
text-align: center;
|
||||
}
|
||||
/* Figures line up column-wise instead of drifting with glyph width. */
|
||||
.chart-page-planet-num { font-variant-numeric: tabular-nums; }
|
||||
.chart-page-boundary {
|
||||
@@ -5774,7 +5781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-dasha-period.is-current {
|
||||
background: var(--color-selected);
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: inset 2px 0 0 var(--color-focus);
|
||||
}
|
||||
.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); }
|
||||
.chart-page-dasha-period.is-zero span { margin-left: auto; }
|
||||
|
||||
@@ -79,7 +79,7 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
|
||||
|
||||
/**
|
||||
* The ascendant is a chart point: nakshatra, lord and pada come from the
|
||||
* engine (BUG-1201); it has no dignity, so the status cell stays empty.
|
||||
* engine (BUG-1201); it has no dignity, so the status cell says 不适用.
|
||||
* `house` is 1 except on the Moon chart, which counts from the Moon.
|
||||
*/
|
||||
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
|
||||
@@ -96,7 +96,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow;
|
||||
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
|
||||
</td>
|
||||
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada ?? "—"}</td>
|
||||
<td>—</td>
|
||||
<td>不适用</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -192,8 +192,8 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
|
||||
<th scope="col">分盘星座</th>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col">本命星座</th>
|
||||
{columns.supportsDignity && <th scope="col">庙旺</th>}
|
||||
{columns.supportsVargottama && <th scope="col">同位</th>}
|
||||
{columns.supportsDignity && <th scope="col" className="chart-page-planet-mid">庙旺</th>}
|
||||
{columns.supportsVargottama && <th scope="col" className="chart-page-planet-mid">同位</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -219,7 +219,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
|
||||
<tr>
|
||||
<th scope="col">行星</th>
|
||||
<th scope="col">今天的位置</th>
|
||||
<th scope="col">速度</th>
|
||||
<th scope="col" className="chart-page-planet-mid">速度</th>
|
||||
<th scope="col">落本命</th>
|
||||
<th scope="col">本命同星</th>
|
||||
</tr>
|
||||
@@ -257,7 +257,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
|
||||
<th scope="col">黄道经度</th>
|
||||
<th scope="col">星座宫</th>
|
||||
<th scope="col">Bhava 宫</th>
|
||||
<th scope="col">换宫</th>
|
||||
<th scope="col" className="chart-page-planet-mid">换宫</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -61,6 +61,7 @@ export const ChatMessageRow = memo(function ChatMessageRow({
|
||||
received: "working",
|
||||
"loading-method": "searching",
|
||||
"chart-calculation": "solving",
|
||||
"compute-queue": "working",
|
||||
"evidence-validation": "working",
|
||||
"answer-composition": "composing",
|
||||
} as const)[message.activity.phase]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* turn is classified or the chart is read (TASK-consult-first-frame-and-pacing-20260928 D1).
|
||||
*/
|
||||
export const CONSULTATION_RECEIVED_LABEL = "收到,正在看你的问题…";
|
||||
export const CONSULTATION_QUEUE_LABEL = "人有点多,排一下队";
|
||||
export const CONSULTATION_QUEUE_FAILED_MESSAGE = "人有点多,这一轮没排上,点数已经退回。";
|
||||
export const CONSULTATION_LOADING_METHOD_LABEL = "正在读取印度占星分析规则…";
|
||||
export const CONSULTATION_CHART_CALCULATION_LABEL = "正在计算本命盘…";
|
||||
export const CONSULTATION_EVIDENCE_VALIDATION_LABEL = "正在核对可用证据…";
|
||||
|
||||
@@ -10,6 +10,9 @@ export const publicActivityPhaseSchema = z.enum([
|
||||
"received",
|
||||
"loading-method",
|
||||
"chart-calculation",
|
||||
// Heavy compute is full. The calculate row keeps this sentence while the
|
||||
// consultation retries, then returns to the chart-calculation label.
|
||||
"compute-queue",
|
||||
"evidence-validation",
|
||||
"answer-composition",
|
||||
]);
|
||||
|
||||
@@ -70,7 +70,7 @@ export function reduceConsultationTimeline(
|
||||
return applyCalculateHints(created);
|
||||
}
|
||||
if (event.type === "activity") {
|
||||
if (event.phase === "chart-calculation") {
|
||||
if (event.phase === "chart-calculation" || event.phase === "compute-queue") {
|
||||
return patchRow(state, CALCULATE_ID, {
|
||||
status: "live",
|
||||
label: event.label,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ConsultationWorkflowError } from "../mastra/consultation-workflow.ts";
|
||||
import {
|
||||
CONSULTATION_QUEUE_FAILED_MESSAGE,
|
||||
CONSULTATION_QUEUE_LABEL,
|
||||
} from "./consultation-activity-labels.ts";
|
||||
|
||||
export { CONSULTATION_QUEUE_FAILED_MESSAGE, CONSULTATION_QUEUE_LABEL };
|
||||
|
||||
export const CONSULTATION_QUEUE_MAX_RETRIES = 2;
|
||||
export const CONSULTATION_QUEUE_MAX_WAIT_MS = 15_000;
|
||||
|
||||
export function isConsultationRateLimitFailure(error: unknown): boolean {
|
||||
if (error instanceof ConsultationWorkflowError) return error.code === "workflow_rate_limited";
|
||||
if (error instanceof Error && error.cause !== undefined) return isConsultationRateLimitFailure(error.cause);
|
||||
return false;
|
||||
}
|
||||
|
||||
function retryDelaySeconds(error: unknown): number | undefined {
|
||||
if (error instanceof ConsultationWorkflowError && error.code === "workflow_rate_limited") {
|
||||
return error.retryAfterSeconds ?? 2;
|
||||
}
|
||||
if (error instanceof Error && error.cause !== undefined) return retryDelaySeconds(error.cause);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function consultationRunFailureMessage(error: unknown, fallback: string): string {
|
||||
return isConsultationRateLimitFailure(error) ? CONSULTATION_QUEUE_FAILED_MESSAGE : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry one consultation workflow call when the heavy-compute gate answers
|
||||
* 429. At most two retries, and the sleeps together stay within 15 seconds.
|
||||
*/
|
||||
export async function retryOnConsultationRateLimit<T>(input: Readonly<{
|
||||
run: () => Promise<T>;
|
||||
onQueue?: () => void | Promise<void>;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}>): Promise<T> {
|
||||
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
let retries = 0;
|
||||
let waitedMs = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
return await input.run();
|
||||
} catch (error) {
|
||||
const retryAfterSeconds = retryDelaySeconds(error);
|
||||
if (retryAfterSeconds === undefined || retries >= CONSULTATION_QUEUE_MAX_RETRIES) throw error;
|
||||
const remainingMs = CONSULTATION_QUEUE_MAX_WAIT_MS - waitedMs;
|
||||
if (remainingMs <= 0) throw error;
|
||||
const waitMs = Math.min(retryAfterSeconds * 1000, remainingMs);
|
||||
retries += 1;
|
||||
waitedMs += waitMs;
|
||||
await input.onQueue?.();
|
||||
await sleep(waitMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,11 @@ export const personalReportCreateRequestSchema = z.object({
|
||||
|
||||
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
|
||||
|
||||
export type ReportRouteResponse = Readonly<{ status: number; body: Record<string, unknown> }>;
|
||||
export type ReportRouteResponse = Readonly<{
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
export type ReportServicePort = Pick<
|
||||
PersonalReportService,
|
||||
@@ -231,6 +235,10 @@ export type ReportCreateCoreDeps = Readonly<{
|
||||
persistence: ReportServicePort;
|
||||
model: Readonly<{ id: string; configVersion?: number; inputCostMicrousdPerMillion?: number; outputCostMicrousdPerMillion?: number }> | null;
|
||||
billing?: ReportBillingPort;
|
||||
/** Hourly per-user cap. Checked after a replay and the daily cap, before reserve. */
|
||||
consumeReportRateLimit?: () =>
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly retryAfterSeconds: number; readonly message: string };
|
||||
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
|
||||
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
|
||||
skillSnapshot: SkillSnapshot;
|
||||
@@ -387,6 +395,20 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
|
||||
|
||||
let billingReserved = false;
|
||||
if (deps.billing && selectedModel) {
|
||||
if (deps.consumeReportRateLimit) {
|
||||
const limited = deps.consumeReportRateLimit();
|
||||
if (!limited.ok) {
|
||||
return {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(limited.retryAfterSeconds) },
|
||||
body: {
|
||||
error: limited.message,
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
let authorization;
|
||||
try {
|
||||
authorization = await deps.billing.reserve({ userId, requestId: payload.requestId, modelId: selectedModel.id });
|
||||
|
||||
@@ -23,6 +23,18 @@ export const requestRateLimits = {
|
||||
ephemeris: { limit: 30, windowMs: 60_000 },
|
||||
synastry: { limit: 8, windowMs: 60 * 60_000 },
|
||||
sessionWrite: { limit: 40, windowMs: 60_000 },
|
||||
// Two windows each: a minute burst and an hour budget (product 2026-10-14).
|
||||
consultMinute: { limit: 10, windowMs: 60_000 },
|
||||
consultHour: { limit: 120, windowMs: 60 * 60_000 },
|
||||
rectificationTurnMinute: { limit: 5, windowMs: 60_000 },
|
||||
rectificationTurnHour: { limit: 40, windowMs: 60 * 60_000 },
|
||||
reportGenerateHour: { limit: 3, windowMs: 60 * 60_000 },
|
||||
} as const;
|
||||
|
||||
export const userRateLimitMessages = {
|
||||
consult: "这一会儿问得有点密,先停一下。",
|
||||
rectificationTurn: "这一会儿校正问得有点密,先停一下。",
|
||||
reportGenerate: "这一小时的报告已经够多了,先停一下。",
|
||||
} as const;
|
||||
|
||||
export type RequestRateLimitBucket = keyof typeof requestRateLimits;
|
||||
@@ -54,30 +66,41 @@ function sweepExpiredRateLimitKeys(
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
|
||||
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
|
||||
consumeCalls += 1;
|
||||
function evaluateRequestRateLimit(
|
||||
input: RequestRateLimitInput,
|
||||
record: boolean,
|
||||
): RequestRateLimitDecision {
|
||||
const now = input.now ?? Date.now();
|
||||
const store = input.store ?? defaultStore();
|
||||
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
|
||||
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
|
||||
if (record) {
|
||||
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
|
||||
consumeCalls += 1;
|
||||
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
|
||||
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
|
||||
}
|
||||
}
|
||||
const windowStart = now - input.windowMs;
|
||||
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
|
||||
if (recent.length >= input.limit) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((recent[0]! + input.windowMs - now) / 1000));
|
||||
store.set(input.key, recent);
|
||||
if (record) store.set(input.key, recent);
|
||||
return { ok: false, retryAfterSeconds };
|
||||
}
|
||||
if (!record) return { ok: true };
|
||||
recent.push(now);
|
||||
store.set(input.key, recent);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
|
||||
return evaluateRequestRateLimit(input, true);
|
||||
}
|
||||
|
||||
export function consumeUserRequestRateLimit(
|
||||
bucket: RequestRateLimitBucket,
|
||||
userId: string,
|
||||
now?: number,
|
||||
store?: RequestRateLimitStore,
|
||||
): RequestRateLimitDecision {
|
||||
const spec = requestRateLimits[bucket];
|
||||
return consumeRequestRateLimit({
|
||||
@@ -85,5 +108,45 @@ export function consumeUserRequestRateLimit(
|
||||
limit: spec.limit,
|
||||
windowMs: spec.windowMs,
|
||||
now,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow the request only when every bucket still has room. A refusal writes
|
||||
* nothing, so the bucket that still had room is not charged for the miss.
|
||||
*/
|
||||
export function consumeUserRateLimitSet(
|
||||
buckets: readonly RequestRateLimitBucket[],
|
||||
userId: string,
|
||||
now?: number,
|
||||
store?: RequestRateLimitStore,
|
||||
): RequestRateLimitDecision {
|
||||
let retryAfterSeconds = 0;
|
||||
for (const bucket of buckets) {
|
||||
const spec = requestRateLimits[bucket];
|
||||
const decision = evaluateRequestRateLimit({
|
||||
key: `${bucket}:${userId}`,
|
||||
limit: spec.limit,
|
||||
windowMs: spec.windowMs,
|
||||
now,
|
||||
store,
|
||||
}, false);
|
||||
if (!decision.ok) retryAfterSeconds = Math.max(retryAfterSeconds, decision.retryAfterSeconds);
|
||||
}
|
||||
if (retryAfterSeconds > 0) return { ok: false, retryAfterSeconds };
|
||||
for (const bucket of buckets) consumeUserRequestRateLimit(bucket, userId, now, store);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function userRateLimitDeniedResponse(retryAfterSeconds: number, message: string) {
|
||||
return {
|
||||
status: 429 as const,
|
||||
headers: { "Retry-After": String(retryAfterSeconds) },
|
||||
body: {
|
||||
error: message,
|
||||
code: "rate_limited" as const,
|
||||
retryAfterSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "./consultation-step-timing.ts";
|
||||
import { createVisibleTextTransformer } from "./stream-text-response.ts";
|
||||
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
|
||||
import { consultationRunFailureMessage } from "./consultation-workflow-retry.ts";
|
||||
import { logTruncatedReasoning } from "./consultation-budget.ts";
|
||||
import {
|
||||
classifyPass4,
|
||||
@@ -1177,7 +1178,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
send(controller, {
|
||||
type: "run.failed",
|
||||
code,
|
||||
message: runFailedMessage(code),
|
||||
message: consultationRunFailureMessage(error, runFailedMessage(code)),
|
||||
...(failureReceipt ? { receipt: failureReceipt } : {}),
|
||||
});
|
||||
if (!disconnected) controller.close();
|
||||
|
||||
@@ -25,8 +25,9 @@ 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 { CONSULTATION_QUEUE_LABEL, retryOnConsultationRateLimit } from "../lib/consultation-workflow-retry.ts";
|
||||
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
|
||||
import {
|
||||
buildEvidenceCard,
|
||||
@@ -176,6 +177,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 +225,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;
|
||||
@@ -1228,6 +1310,36 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
try {
|
||||
const userIntent = ctx.plan?.userIntent ?? input.question;
|
||||
const executions: DomainExecution[] = [];
|
||||
const runQueuedWorkflow = async (
|
||||
toolInput: ReturnType<typeof consultationInputSchema.parse>,
|
||||
domainPlan: ConsultationPlan,
|
||||
progressLabel: string,
|
||||
) => {
|
||||
let queued = false;
|
||||
const workflow = await retryOnConsultationRateLimit({
|
||||
run: () => (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
}),
|
||||
onQueue: async () => {
|
||||
queued = true;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: { phase: "compute-queue", label: CONSULTATION_QUEUE_LABEL },
|
||||
});
|
||||
},
|
||||
});
|
||||
if (queued) {
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: { phase: "chart-calculation", label: progressLabel },
|
||||
});
|
||||
}
|
||||
return workflow;
|
||||
};
|
||||
if (consultDomainConcurrency() === 1) {
|
||||
for (let index = 0; index < domains.length; index += 1) {
|
||||
const domain = domains[index];
|
||||
@@ -1263,13 +1375,11 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
question: userIntent,
|
||||
theme: domain,
|
||||
});
|
||||
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const workflow = await runQueuedWorkflow(
|
||||
toolInput,
|
||||
domainPlan,
|
||||
chartCalculationProgressLabel(index + 1, domains.length),
|
||||
);
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
});
|
||||
@@ -1293,7 +1403,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
},
|
||||
});
|
||||
}
|
||||
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
|
||||
const parallelExecutions = await Promise.all(domains.map(async (domain, domainIndex) => {
|
||||
if (!domain) return null;
|
||||
const domainPlan = ctx.plan
|
||||
&& domains.length === 1
|
||||
@@ -1315,13 +1425,11 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
question: userIntent,
|
||||
theme: domain,
|
||||
});
|
||||
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const workflow = await runQueuedWorkflow(
|
||||
toolInput,
|
||||
domainPlan,
|
||||
chartCalculationProgressLabel(domainIndex + 1, domains.length),
|
||||
);
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
});
|
||||
|
||||
@@ -102,14 +102,23 @@ export type ConsultationWorkflowFailureCode = typeof consultationWorkflowFailure
|
||||
|
||||
export class ConsultationWorkflowError extends Error {
|
||||
readonly code: ConsultationWorkflowFailureCode;
|
||||
readonly retryAfterSeconds?: number;
|
||||
|
||||
constructor(code: ConsultationWorkflowFailureCode, message: string) {
|
||||
constructor(code: ConsultationWorkflowFailureCode, message: string, retryAfterSeconds?: number) {
|
||||
super(message);
|
||||
this.name = "ConsultationWorkflowError";
|
||||
this.code = code;
|
||||
if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterHeader(raw: string | null): number {
|
||||
if (!raw) return 2;
|
||||
const seconds = Number(raw.trim());
|
||||
if (!Number.isFinite(seconds)) return 2;
|
||||
return Math.max(1, Math.ceil(seconds));
|
||||
}
|
||||
|
||||
function httpFailureCode(status: number): ConsultationWorkflowFailureCode {
|
||||
if (status === 400) return "workflow_bad_request";
|
||||
if (status === 403) return "workflow_forbidden";
|
||||
@@ -174,6 +183,7 @@ export async function runConsultationWorkflow(
|
||||
throw new ConsultationWorkflowError(
|
||||
response.ok ? "workflow_empty_response" : httpFailureCode(response.status),
|
||||
data?.error || data?.message || `Jyotish API returned ${response.status}`,
|
||||
response.status === 429 ? parseRetryAfterHeader(response.headers.get("retry-after")) : undefined,
|
||||
);
|
||||
}
|
||||
const parsed = consultationWorkflowResponseSchema.safeParse(data);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Request-body probe for issue #65.
|
||||
*
|
||||
* --dry-run needs no model key. It sends one agent.stream per shape through
|
||||
* Mastra's OpenAI-compatible channel and prints the thinking / reasoning_effort
|
||||
* / max_tokens fields of each request. A fake fetch never leaves the machine.
|
||||
*
|
||||
* Shapes:
|
||||
* (a) consultationGenerationSettings (today's answer settings)
|
||||
* (b) consultationSliceGenerationSettings (nested reasoningEffort, no caller)
|
||||
* (c) providerReasoningEffort low / medium / high / max at the top level
|
||||
*
|
||||
* With DS_KEY or BACKTEST_MODEL_KEY, the same file can ask the provider which
|
||||
* efforts it accepts. This checkout does not run that half.
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { agentGenerationSettings } from "../src/lib/agent-generation-settings.ts";
|
||||
import {
|
||||
consultationContinueGenerationSettings,
|
||||
consultationGenerationSettings,
|
||||
consultationNatalPrepareStep,
|
||||
consultationSliceGenerationSettings,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
|
||||
const MODEL = {
|
||||
providerId: "deepseek",
|
||||
modelId: "deepseek-flash",
|
||||
url: "http://127.0.0.1:9/v1",
|
||||
apiKey: "dummy",
|
||||
};
|
||||
|
||||
const markTool = createTool({
|
||||
id: "run-jyotish-consultation",
|
||||
description: "Mark the calculation done.",
|
||||
inputSchema: z.object({}).strict(),
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
function sse(chunks: unknown[]) {
|
||||
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function toolCallChunk() {
|
||||
return sse([
|
||||
{
|
||||
id: "chatcmpl-tool",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "run-jyotish-consultation", arguments: "" },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-tool",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] },
|
||||
finish_reason: null,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-tool",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function textChunk() {
|
||||
return sse([
|
||||
{
|
||||
id: "chatcmpl-text",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-text",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, completion_tokens_details: { reasoning_tokens: 0 } },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function requestFields(body: unknown) {
|
||||
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
||||
return {
|
||||
thinking: record.thinking ?? null,
|
||||
reasoning_effort: record.reasoning_effort ?? null,
|
||||
max_tokens: record.max_tokens ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureStreamBodies(input: {
|
||||
settings: ReturnType<typeof consultationGenerationSettings>;
|
||||
prepareStep?: (step: { stepNumber: number }) => { toolChoice?: "auto"; activeTools?: string[]; providerOptions?: unknown };
|
||||
steps: 1 | 2;
|
||||
}): Promise<unknown[]> {
|
||||
const bodies: unknown[] = [];
|
||||
const previous = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
const raw = init?.body;
|
||||
bodies.push(JSON.parse(typeof raw === "string" ? raw : "{}"));
|
||||
return bodies.length === 1 && input.steps === 2 ? toolCallChunk() : textChunk();
|
||||
};
|
||||
try {
|
||||
const agent = new Agent({
|
||||
id: `answer-effort-probe-${bodies.length}-${Date.now()}`,
|
||||
name: "answer-effort-probe",
|
||||
instructions: "Reply ok.",
|
||||
model: MODEL,
|
||||
tools: { "run-jyotish-consultation": markTool },
|
||||
});
|
||||
const result = await agent.stream([{ role: "user", content: "ping" }], {
|
||||
maxSteps: input.steps,
|
||||
...input.settings,
|
||||
...(input.prepareStep ? { prepareStep: input.prepareStep } : {}),
|
||||
} as never);
|
||||
const drained: unknown[] = [];
|
||||
for await (const chunk of result.fullStream) drained.push(chunk);
|
||||
if (drained.length === 0) throw new Error("probe stream produced no chunks");
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes("--dry-run")) {
|
||||
console.log("A/B 未跑:缺模型 key。只做请求体拦截时加上 --dry-run。");
|
||||
return;
|
||||
}
|
||||
const model = MODEL;
|
||||
const natal = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(model),
|
||||
prepareStep: (input) => consultationNatalPrepareStep({
|
||||
stepNumber: input.stepNumber,
|
||||
answerReady: input.stepNumber >= 1,
|
||||
}),
|
||||
steps: 2,
|
||||
});
|
||||
const slice = await captureStreamBodies({
|
||||
settings: consultationSliceGenerationSettings(model),
|
||||
steps: 1,
|
||||
});
|
||||
const topLevel = [];
|
||||
for (const effort of ["low", "medium", "high", "max"] as const) {
|
||||
const bodies = await captureStreamBodies({
|
||||
settings: agentGenerationSettings(model, { thinking: "enabled", providerReasoningEffort: effort }),
|
||||
steps: 1,
|
||||
});
|
||||
topLevel.push({ effort, fields: requestFields(bodies[0]) });
|
||||
}
|
||||
const continued = await captureStreamBodies({
|
||||
settings: consultationContinueGenerationSettings(model),
|
||||
steps: 1,
|
||||
});
|
||||
const report = {
|
||||
fixtureProvenance: "frontend/tests/answer-effort-probe.mts --dry-run",
|
||||
a_consultationGenerationSettings: natal.map(requestFields),
|
||||
b_consultationSliceGenerationSettings: slice.map(requestFields),
|
||||
c_providerReasoningEffort: topLevel,
|
||||
continue: continued.map(requestFields),
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (process.argv.includes("--save-baseline")) {
|
||||
const file = new URL("./fixtures/answer-effort-switch-20261013.json", import.meta.url);
|
||||
writeFileSync(file, `${JSON.stringify({
|
||||
fixtureProvenance: "node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run --save-baseline(开关尚未读取环境变量时的整份请求体)",
|
||||
natal: natal,
|
||||
continue: continued,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]
|
||||
? new URL(`file:///${process.argv[1].replace(/\\/g, "/")}`).href === import.meta.url
|
||||
: false;
|
||||
if (invoked) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Answer-step reasoning effort (issue #65).
|
||||
* The unset bodies were captured before this switch read the environment:
|
||||
* node node_modules/tsx/dist/cli.mjs tests/answer-effort-probe.mts --dry-run --save-baseline
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
consultAnswerReasoningEffort,
|
||||
consultationContinueGenerationSettings,
|
||||
consultationGeneralPrepareStep,
|
||||
consultationGenerationSettings,
|
||||
consultationNatalPrepareStep,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
import { captureStreamBodies } from "./answer-effort-probe.mts";
|
||||
|
||||
const MODEL = {
|
||||
providerId: "deepseek",
|
||||
modelId: "deepseek-flash",
|
||||
url: "http://127.0.0.1:9/v1",
|
||||
apiKey: "dummy",
|
||||
};
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL("./fixtures/answer-effort-switch-20261013.json", import.meta.url), "utf8")) as {
|
||||
natal: unknown[];
|
||||
continue: unknown[];
|
||||
};
|
||||
|
||||
function withoutEffort(body: unknown) {
|
||||
const copy = { ...(body as Record<string, unknown>) };
|
||||
delete copy.reasoning_effort;
|
||||
return copy;
|
||||
}
|
||||
|
||||
test("an illegal effort warns once and stays unset", () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
const seen: unknown[][] = [];
|
||||
const warn = console.warn;
|
||||
console.warn = (...args: unknown[]) => { seen.push(args); };
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = "turbo";
|
||||
try {
|
||||
assert.equal(consultAnswerReasoningEffort(), undefined);
|
||||
assert.equal(consultAnswerReasoningEffort(), undefined);
|
||||
assert.equal(seen.length, 1);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("unset effort keeps the pre-change request bodies", async () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
try {
|
||||
const natal = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(MODEL),
|
||||
prepareStep: (input) => consultationNatalPrepareStep({
|
||||
stepNumber: input.stepNumber,
|
||||
answerReady: input.stepNumber >= 1,
|
||||
model: MODEL,
|
||||
}),
|
||||
steps: 2,
|
||||
});
|
||||
const continued = await captureStreamBodies({
|
||||
settings: consultationContinueGenerationSettings(MODEL),
|
||||
steps: 1,
|
||||
});
|
||||
assert.deepEqual(natal, fixture.natal);
|
||||
assert.deepEqual(continued, fixture.continue);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("low effort is only on the answer step", async () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = "low";
|
||||
try {
|
||||
const natal = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(MODEL),
|
||||
prepareStep: (input) => consultationNatalPrepareStep({
|
||||
stepNumber: input.stepNumber,
|
||||
answerReady: input.stepNumber >= 1,
|
||||
model: MODEL,
|
||||
}),
|
||||
steps: 2,
|
||||
}) as Record<string, unknown>[];
|
||||
assert.equal(natal[0]?.reasoning_effort, undefined);
|
||||
assert.deepEqual(natal[0], fixture.natal[0]);
|
||||
assert.equal(natal[1]?.reasoning_effort, "low");
|
||||
assert.deepEqual(withoutEffort(natal[1]), fixture.natal[1]);
|
||||
const continued = await captureStreamBodies({
|
||||
settings: consultationContinueGenerationSettings(MODEL),
|
||||
steps: 1,
|
||||
});
|
||||
assert.deepEqual(continued, fixture.continue);
|
||||
const daily = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(MODEL),
|
||||
prepareStep: (input) => consultationGeneralPrepareStep({ stepNumber: input.stepNumber, model: MODEL }),
|
||||
steps: 1,
|
||||
}) as Record<string, unknown>[];
|
||||
assert.equal(daily[0]?.reasoning_effort, "low");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||
}
|
||||
});
|
||||
@@ -90,6 +90,7 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses"
|
||||
assert.match(markup, /<dt>交点<\/dt>/);
|
||||
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
|
||||
assert.match(markup, /<caption>Bhava · Sripati<\/caption>/);
|
||||
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">换宫<\/th>/);
|
||||
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
|
||||
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
|
||||
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/td>/g) ?? []).length, shifted.length);
|
||||
|
||||
@@ -87,6 +87,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
|
||||
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
|
||||
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
|
||||
const table = markup.slice(markup.indexOf("月亮盘"));
|
||||
const ascendantRow = table.match(/<th scope="row">[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
|
||||
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
|
||||
assert.match(ascendantRow, /is-sun/);
|
||||
assert.match(ascendantRow, />1</);
|
||||
assert.match(ascendantRow, />不适用</);
|
||||
});
|
||||
|
||||
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
|
||||
@@ -125,7 +131,8 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
|
||||
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
|
||||
for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
|
||||
for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
|
||||
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">速度<\/th>/);
|
||||
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
|
||||
assert.match(markup, /<dl class="chart-page-params-table">/);
|
||||
assert.match(markup, /<dt>岁差<\/dt>/);
|
||||
|
||||
@@ -48,7 +48,21 @@ test("the Vedic waiting slot reserves the result legend band without changing th
|
||||
|
||||
test("only the house column in frozen natal and Moon tables overrides centering", () => {
|
||||
assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
|
||||
assert.match(cssDeclarations(".chart-page-planet-table th.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
|
||||
assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/);
|
||||
for (const child of [3, 6, 7]) {
|
||||
assert.match(
|
||||
cssDeclarations(`.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(${child})`, globalStyles),
|
||||
/text-align:\s*center/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the current dasha card keeps its tint and drops the left accent line", () => {
|
||||
const current = cssDeclarations(".chart-page-dasha-period.is-current", globalStyles);
|
||||
assert.match(current, /background:\s*var\(--color-selected\)/);
|
||||
assert.match(current, /border-color:\s*var\(--color-border-strong\)/);
|
||||
assert.doesNotMatch(current, /box-shadow/);
|
||||
});
|
||||
|
||||
test("wide chart-page content still scrolls inside its own box instead of widening the page", () => {
|
||||
|
||||
@@ -99,8 +99,11 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc
|
||||
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
|
||||
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
|
||||
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
|
||||
// 原值:上升行状态格 <td>—</td>
|
||||
// 新值:<td>不适用</td>
|
||||
// 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。
|
||||
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
|
||||
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>—</td>';
|
||||
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>不适用</td>';
|
||||
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
|
||||
assert.equal(table, baseline.replace(dashes, filled));
|
||||
});
|
||||
@@ -113,6 +116,8 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async (
|
||||
const ascendant = row(render(view, "D1"), "上升");
|
||||
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
|
||||
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
|
||||
assert.match(ascendant, />1</, "pada 1");
|
||||
assert.match(ascendant, />不适用</);
|
||||
// An older cached chart without the fields still renders, with dashes.
|
||||
const chart = structuredClone(golden.chart);
|
||||
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
|
||||
@@ -136,6 +141,8 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
|
||||
const markup = render(view, "D9");
|
||||
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
|
||||
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
|
||||
assert.match(header, /<th scope="col" class="chart-page-planet-mid">庙旺<\/th>/);
|
||||
assert.match(header, /<th scope="col" class="chart-page-planet-mid">同位<\/th>/);
|
||||
assert.doesNotMatch(header, /星宿|宿主|pada/);
|
||||
// Same wording as the D1 status column (BUG-1202), friendship levels included.
|
||||
// 原值:同位行星单元格是「同位」,非同位是「—」
|
||||
|
||||
@@ -50,6 +50,9 @@ test("the consult route starts the general / no-birth-minute loop on the answer
|
||||
);
|
||||
// That branch still streams on the shared loop signal and keeps its traces.
|
||||
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,/);
|
||||
});
|
||||
|
||||
@@ -179,7 +179,10 @@ test("consult user turn no longer dumps tool JSON that the model would parrot",
|
||||
assert.doesNotMatch(chartBranch, /JSON\.stringify\(toolInput\)/);
|
||||
assert.doesNotMatch(chartBranch, /经过服务端校验的工具参数/);
|
||||
assert.match(route, /文末技法审计表/);
|
||||
assert.match(chartBranch, /await runConsultationWorkflow\(toolInput/);
|
||||
// 原值: /await runConsultationWorkflow\(toolInput/
|
||||
// 新值: /runConsultationWorkflow\(toolInput/
|
||||
// 原因: 调用包进 retryOnConsultationRateLimit,await 不再紧贴函数名。仍锁工作流入参是 toolInput;用户轮不塞工具 JSON 的断言不动。
|
||||
assert.match(chartBranch, /runConsultationWorkflow\(toolInput/);
|
||||
});
|
||||
|
||||
// 原值: test "the opener is a shape — 反差, 谁推谁修, 扮演哪个象, then at most three actions"(锁 OPENER SHAPE 四步、≤ 20 字行动、降级路线「第 3 步照给」)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { ConsultationWorkflowError } from "../src/mastra/consultation-workflow.ts";
|
||||
import { CONSULTATION_QUEUE_LABEL } from "../src/lib/consultation-activity-labels.ts";
|
||||
import {
|
||||
CONSULTATION_QUEUE_FAILED_MESSAGE,
|
||||
consultationRunFailureMessage,
|
||||
isConsultationRateLimitFailure,
|
||||
retryOnConsultationRateLimit,
|
||||
} from "../src/lib/consultation-workflow-retry.ts";
|
||||
import {
|
||||
emptyConsultationTimeline,
|
||||
reduceConsultationTimelineEvents,
|
||||
} from "../src/lib/consultation-run-timeline.ts";
|
||||
|
||||
test("a 429 is retried once and the queue line is shown before success", async () => {
|
||||
let calls = 0;
|
||||
const queued: string[] = [];
|
||||
const sleeps: number[] = [];
|
||||
const result = await retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
|
||||
return "ok";
|
||||
},
|
||||
onQueue: () => { queued.push(CONSULTATION_QUEUE_LABEL); },
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
});
|
||||
assert.equal(result, "ok");
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(sleeps, [2000]);
|
||||
assert.deepEqual(queued, ["人有点多,排一下队"]);
|
||||
});
|
||||
|
||||
test("three 429s stop within two retries and 15 seconds", async () => {
|
||||
let calls = 0;
|
||||
let slept = 0;
|
||||
await assert.rejects(
|
||||
() => retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
|
||||
},
|
||||
sleep: async (ms) => { slept += ms; },
|
||||
}),
|
||||
(error: unknown) => {
|
||||
assert.equal(isConsultationRateLimitFailure(error), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(slept, 4_000);
|
||||
assert.ok(slept <= 15_000);
|
||||
assert.equal(
|
||||
consultationRunFailureMessage(new ConsultationWorkflowError("workflow_rate_limited", "busy", 2), "其他失败"),
|
||||
CONSULTATION_QUEUE_FAILED_MESSAGE,
|
||||
);
|
||||
assert.equal(consultationRunFailureMessage(new Error("other"), "其他失败"), "其他失败");
|
||||
});
|
||||
|
||||
test("Retry-After cannot push the total wait past 15 seconds", async () => {
|
||||
let calls = 0;
|
||||
let slept = 0;
|
||||
await assert.rejects(() => retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 20);
|
||||
},
|
||||
sleep: async (ms) => { slept += ms; },
|
||||
}));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(slept, 15_000);
|
||||
});
|
||||
|
||||
test("the queue sentence replaces the calculate row and the chart label comes back", () => {
|
||||
const calculating = reduceConsultationTimelineEvents([
|
||||
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算个人星盘" },
|
||||
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
|
||||
], emptyConsultationTimeline());
|
||||
const queued = reduceConsultationTimelineEvents([
|
||||
{ type: "activity", phase: "compute-queue", label: CONSULTATION_QUEUE_LABEL },
|
||||
], calculating);
|
||||
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.label, "人有点多,排一下队");
|
||||
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.status, "live");
|
||||
const resumed = reduceConsultationTimelineEvents([
|
||||
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
|
||||
], queued);
|
||||
assert.equal(resumed.rows.find((row) => row.kind === "calculate")?.label, "正在计算本命盘…");
|
||||
});
|
||||
|
||||
test("a failed consultation still settles through the existing cancel path before the queue sentence", () => {
|
||||
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
|
||||
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const onErrorAt = stream.indexOf("await options.onError?.");
|
||||
const messageAt = stream.indexOf("consultationRunFailureMessage(error, runFailedMessage(code))");
|
||||
assert.ok(onErrorAt > 0 && messageAt > onErrorAt);
|
||||
assert.match(consult, /onError: \(error\) => settleRun\(\s*cancel/);
|
||||
assert.match(consult, /else await cancel\(\)/);
|
||||
assert.match(consult, /retryOnConsultationRateLimit/);
|
||||
assert.match(consult, /CONSULTATION_QUEUE_FAILED_MESSAGE/);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"fixtureProvenance": "node node_modules/tsx/dist/cli.mjs scripts/research/answer-effort-probe.mts --dry-run --save-baseline(开关尚未读取环境变量时的整份请求体)",
|
||||
"natal": [
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"max_tokens": 24576,
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Reply ok."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ping"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run-jyotish-consultation",
|
||||
"description": "Mark the calculation done.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"stream": true
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"max_tokens": 24576,
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Reply ok."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ping"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run-jyotish-consultation",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "{\"ok\":true}"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run-jyotish-consultation",
|
||||
"description": "Mark the calculation done.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"stream": true
|
||||
}
|
||||
],
|
||||
"continue": [
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"max_tokens": 16384,
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Reply ok."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ping"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run-jyotish-consultation",
|
||||
"description": "Mark the calculation done.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"stream": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).",
|
||||
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1202): varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions. 2026-10-15 (BUG-1352): chart.ascendant nakshatra, nakshatra_pada and nakshatra_lord are the fields returned by JyotishAPIHandler._compute_chart_sync for this birth (tests/test_api_chart_ascendant_nakshatra.py); they are not filled in by hand.",
|
||||
"chart": {
|
||||
"success": true,
|
||||
"birth": {
|
||||
|
||||
@@ -1525,3 +1525,19 @@ test("core read: the detail response has no coverSubject, and never the fingerpr
|
||||
assert.doesNotMatch(body, new RegExp(BIRTH_FP));
|
||||
assert.doesNotMatch(body, /coverSubject|birthFingerprint|birth_date|latitude|longitude|timezone|birthPlaceLabel|北京/);
|
||||
});
|
||||
|
||||
// 原值: 插在「429 at the daily limit」和「502 model_unavailable」之间。
|
||||
// 新值: 整段挪到文件末尾,断言不变。
|
||||
// 原因: 插在中部让 privacy 白名单按行号锁定的三行各下移 13 行。挪到末尾后那三行回到原行号,SAFE_LINES 不改。
|
||||
test("core create: hourly rate limit returns 429 and does not reserve", async () => {
|
||||
const billing = billingHarness();
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
billing: billing.billing,
|
||||
consumeReportRateLimit: () => ({ ok: false, retryAfterSeconds: 12, message: "这一小时的报告已经够多了,先停一下。" }),
|
||||
}));
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(response.body.code, "rate_limited");
|
||||
assert.equal(response.body.retryAfterSeconds, 12);
|
||||
assert.equal(response.headers?.["Retry-After"], "12");
|
||||
assert.equal(billing.reservations, 0);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
consumeRequestRateLimit,
|
||||
consumeUserRateLimitSet,
|
||||
requestRateLimits,
|
||||
resetRequestRateLimitMaintenanceForTests,
|
||||
userRateLimitDeniedResponse,
|
||||
userRateLimitMessages,
|
||||
} from "../src/lib/request-rate-limit.ts";
|
||||
|
||||
test("a user is allowed up to the window limit and then must wait", () => {
|
||||
@@ -110,3 +115,98 @@ test("keys past the longest seen window are swept, and a one-hour key survives 6
|
||||
assert.equal(mixed.has("ephemeris:person"), true);
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
});
|
||||
|
||||
test("consult allows 10 per minute and refuses the 11th without charging the hour bucket further", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const decision = consumeUserRateLimitSet(
|
||||
["consultMinute", "consultHour"],
|
||||
"person",
|
||||
1_000 + index,
|
||||
store,
|
||||
);
|
||||
assert.deepEqual(decision, { ok: true });
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 2_000, store);
|
||||
assert.equal(denied.ok, false);
|
||||
if (!denied.ok) assert.equal(denied.retryAfterSeconds, 59);
|
||||
assert.equal(store.get("consultMinute:person")?.length, 10);
|
||||
assert.equal(store.get("consultHour:person")?.length, 10);
|
||||
assert.equal(requestRateLimits.consultMinute.limit, 10);
|
||||
assert.equal(requestRateLimits.consultHour.limit, 120);
|
||||
});
|
||||
|
||||
test("the hour bucket can refuse while the minute bucket still has room, and neither bucket records the miss", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: "consultHour:person",
|
||||
limit: 120,
|
||||
windowMs: 60 * 60_000,
|
||||
now: index * 1_000,
|
||||
store,
|
||||
});
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 130_000, store);
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(store.has("consultMinute:person"), false);
|
||||
assert.equal(store.get("consultHour:person")?.length, 120);
|
||||
});
|
||||
|
||||
test("rectification turns allow 5 per minute and 40 per hour, reports allow 3 per hour", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
assert.deepEqual(
|
||||
consumeUserRateLimitSet(["rectificationTurnMinute", "rectificationTurnHour"], "person", 5_000 + index, store),
|
||||
{ ok: true },
|
||||
);
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(
|
||||
["rectificationTurnMinute", "rectificationTurnHour"],
|
||||
"person",
|
||||
5_100,
|
||||
store,
|
||||
);
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(store.get("rectificationTurnMinute:person")?.length, 5);
|
||||
assert.equal(requestRateLimits.rectificationTurnMinute.limit, 5);
|
||||
assert.equal(requestRateLimits.rectificationTurnHour.limit, 40);
|
||||
assert.equal(requestRateLimits.reportGenerateHour.limit, 3);
|
||||
assert.equal(requestRateLimits.reportGenerateHour.windowMs, 60 * 60_000);
|
||||
|
||||
const reports = new Map<string, number[]>();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
assert.deepEqual(
|
||||
consumeUserRateLimitSet(["reportGenerateHour"], "person", 8_000 + index, reports),
|
||||
{ ok: true },
|
||||
);
|
||||
}
|
||||
const reportDenied = consumeUserRateLimitSet(["reportGenerateHour"], "person", 9_000, reports);
|
||||
assert.equal(reportDenied.ok, false);
|
||||
assert.equal(reports.get("reportGenerateHour:person")?.length, 3);
|
||||
});
|
||||
|
||||
test("a denied response carries 429, rate_limited, and Retry-After", () => {
|
||||
const denied = userRateLimitDeniedResponse(12, userRateLimitMessages.consult);
|
||||
assert.equal(denied.status, 429);
|
||||
assert.equal(denied.headers["Retry-After"], "12");
|
||||
assert.deepEqual(denied.body, {
|
||||
error: "这一会儿问得有点密,先停一下。",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test("consult, rectification turns, and report creation check the limit before they reserve", () => {
|
||||
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const caseOpen = readFileSync(new URL("../src/app/api/rectification/cases/open/route.ts", import.meta.url), "utf8");
|
||||
const reports = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
const reportCore = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8");
|
||||
assert.ok(consult.indexOf("consumeUserRateLimitSet(") < consult.indexOf("reserve_consultation_usage"));
|
||||
assert.match(consult, /userRateLimitDeniedResponse\(/);
|
||||
assert.ok(rectification.indexOf("consumeUserRateLimitSet(") < rectification.indexOf("respondWithRectificationAgentTurn("));
|
||||
assert.equal(caseOpen.includes("consumeUserRateLimitSet"), false);
|
||||
assert.match(reports, /consumeUserRateLimitSet\(\["reportGenerateHour"\]/);
|
||||
assert.ok(reportCore.indexOf("if (deps.consumeReportRateLimit)") < reportCore.indexOf("deps.billing.reserve"));
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Fail-fast bounded concurrency for heavy Jyotish API compute endpoints.
|
||||
"""Bounded concurrency for heavy Jyotish API compute endpoints.
|
||||
|
||||
Request threads that run rectification scans or high-rigor workflows share one
|
||||
process-wide semaphore sized for the 2 vCPU production host. Saturated requests
|
||||
return immediately; they are not queued. Health checks and other light routes
|
||||
must not call this gate.
|
||||
Request threads that run rectification scans or consultation workflows share one
|
||||
process-wide semaphore sized for the 2 vCPU production host. A saturated request
|
||||
waits up to 8 seconds when fewer than 4 threads are already waiting. If that
|
||||
queue is full or the wait expires, the caller still gets HTTP 429 and
|
||||
Retry-After. A thread is never parked without a deadline. Health checks and
|
||||
other light routes must not call this gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +17,8 @@ import time
|
||||
|
||||
DEFAULT_CONCURRENCY = 2
|
||||
DEFAULT_RETRY_AFTER_SECONDS = 2
|
||||
DEFAULT_QUEUE_WAIT_SECONDS = 8.0
|
||||
DEFAULT_MAX_WAITERS = 4
|
||||
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
|
||||
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
|
||||
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
|
||||
@@ -51,6 +55,9 @@ _state_lock = threading.Lock()
|
||||
_semaphore: threading.BoundedSemaphore | None = None
|
||||
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
|
||||
_limit = DEFAULT_CONCURRENCY
|
||||
_wait_seconds = DEFAULT_QUEUE_WAIT_SECONDS
|
||||
_max_waiters = DEFAULT_MAX_WAITERS
|
||||
_waiters = 0
|
||||
_holders: list[dict] = []
|
||||
_holder_seq = 0
|
||||
|
||||
@@ -73,7 +80,7 @@ class _HeldSlot:
|
||||
|
||||
|
||||
class HeavyComputeBusy(RuntimeError):
|
||||
"""No heavy-compute slot is free; callers must fail fast with HTTP 429."""
|
||||
"""The short queue could not take this request; callers return HTTP 429."""
|
||||
|
||||
error_code = "ERR_COMPUTE_BUSY"
|
||||
|
||||
@@ -102,9 +109,11 @@ def reset_heavy_compute_gate(
|
||||
*,
|
||||
limit: int | None = None,
|
||||
retry_after_seconds: int | None = None,
|
||||
wait_seconds: float | None = None,
|
||||
max_waiters: int | None = None,
|
||||
) -> None:
|
||||
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
|
||||
global _semaphore, _retry_after_seconds, _limit, _holders
|
||||
global _semaphore, _retry_after_seconds, _limit, _holders, _wait_seconds, _max_waiters, _waiters
|
||||
resolved_limit = (
|
||||
limit
|
||||
if limit is not None
|
||||
@@ -115,9 +124,14 @@ def reset_heavy_compute_gate(
|
||||
if retry_after_seconds is not None
|
||||
else _parse_positive_int(os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS)
|
||||
)
|
||||
resolved_wait = DEFAULT_QUEUE_WAIT_SECONDS if wait_seconds is None else max(0.0, float(wait_seconds))
|
||||
resolved_waiters = DEFAULT_MAX_WAITERS if max_waiters is None else max(0, int(max_waiters))
|
||||
with _state_lock:
|
||||
_retry_after_seconds = resolved_retry
|
||||
_limit = resolved_limit
|
||||
_wait_seconds = resolved_wait
|
||||
_max_waiters = resolved_waiters
|
||||
_waiters = 0
|
||||
_holders = []
|
||||
_semaphore = threading.BoundedSemaphore(resolved_limit)
|
||||
|
||||
@@ -143,35 +157,61 @@ def _holder_view(now: float) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _acquire_recorded(path: str) -> _HeldSlot:
|
||||
"""Take one permit and remember which path holds it. Fail fast when full."""
|
||||
def _remember_locked(path: str, semaphore: threading.BoundedSemaphore) -> _HeldSlot:
|
||||
"""Record a permit that this thread already holds. Caller owns ``_state_lock``."""
|
||||
global _holder_seq
|
||||
_holder_seq += 1
|
||||
holder_id = _holder_seq
|
||||
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
|
||||
return _HeldSlot(semaphore, holder_id)
|
||||
|
||||
|
||||
def _reject_busy(path: str, retry_after: int) -> None:
|
||||
with _state_lock:
|
||||
snapshot = _holder_view(time.monotonic())
|
||||
_LOG.warning(
|
||||
"heavy_compute_busy rejected_path=%s holders=%s",
|
||||
path,
|
||||
snapshot,
|
||||
)
|
||||
raise HeavyComputeBusy(retry_after)
|
||||
|
||||
|
||||
def _acquire_recorded(path: str) -> _HeldSlot:
|
||||
"""Take one permit. Wait briefly when full; still raise once the queue cannot."""
|
||||
global _waiters
|
||||
with _state_lock:
|
||||
semaphore, retry_after = _ensure_locked()
|
||||
if not semaphore.acquire(blocking=False):
|
||||
snapshot = _holder_view(time.monotonic())
|
||||
failed = True
|
||||
slot = None
|
||||
if semaphore.acquire(blocking=False):
|
||||
return _remember_locked(path, semaphore)
|
||||
if _waiters >= _max_waiters:
|
||||
queued = False
|
||||
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
|
||||
_waiters += 1
|
||||
queued = True
|
||||
wait_seconds = _wait_seconds
|
||||
if not queued:
|
||||
_reject_busy(path, retry_after)
|
||||
acquired = False
|
||||
try:
|
||||
acquired = bool(semaphore.acquire(blocking=True, timeout=wait_seconds))
|
||||
finally:
|
||||
with _state_lock:
|
||||
_waiters -= 1
|
||||
if not acquired:
|
||||
_reject_busy(path, retry_after)
|
||||
with _state_lock:
|
||||
return _remember_locked(path, semaphore)
|
||||
|
||||
|
||||
def heavy_compute_waiter_count() -> int:
|
||||
"""Threads parked inside the short queue. Tests use this to avoid races."""
|
||||
with _state_lock:
|
||||
return _waiters
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
|
||||
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
|
||||
"""Acquire a slot for a gated path. Light paths return None."""
|
||||
if not is_heavy_compute_path(path):
|
||||
return None
|
||||
return _acquire_recorded(path)
|
||||
|
||||
@@ -2589,12 +2589,24 @@ def _vedastro_runtime_fingerprint() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _chart_ascendant_nakshatra(ascendant_data: dict, lon: float) -> dict:
|
||||
"""Keep the engine's nakshatra fields when /api/chart rebuilds the ascendant."""
|
||||
fields = {
|
||||
key: ascendant_data[key]
|
||||
for key in ('nakshatra', 'nakshatra_pada', 'nakshatra_lord')
|
||||
if ascendant_data.get(key) not in (None, '')
|
||||
}
|
||||
if 'nakshatra' in fields:
|
||||
return fields
|
||||
return _load_local_module('jyotish_engine')._nakshatra_fields(lon)
|
||||
|
||||
|
||||
def _build_api_chart_cache_payload(body: dict) -> dict:
|
||||
import api_scratch_retention as retention
|
||||
|
||||
today = body.get('today') or body.get('current_date')
|
||||
return {
|
||||
'cache_schema_version': 6,
|
||||
'cache_schema_version': 7,
|
||||
'birth': retention.normalize_chart_cache_birth(body),
|
||||
'calculation': {
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
@@ -6659,6 +6671,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
'degree': round(asc_lon % 30, 2),
|
||||
'degree_in_sign': round(asc_lon % 30, 2),
|
||||
'lon': round(asc_lon, 4),
|
||||
**_chart_ascendant_nakshatra(ascendant_data, asc_lon),
|
||||
},
|
||||
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
|
||||
'dasha': {
|
||||
|
||||
+89
-412
@@ -109,6 +109,94 @@ if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
try:
|
||||
from scripts.report_render.envelope import (
|
||||
_bool_text,
|
||||
_envelope_dict,
|
||||
_envelope_list,
|
||||
_has_executed_payload,
|
||||
_source_dict,
|
||||
_unwrap,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
from report_render.envelope import (
|
||||
_bool_text,
|
||||
_envelope_dict,
|
||||
_envelope_list,
|
||||
_has_executed_payload,
|
||||
_source_dict,
|
||||
_unwrap,
|
||||
)
|
||||
try:
|
||||
from scripts.report_render.sections_misc import (
|
||||
_chart_planets_from_positions,
|
||||
_domi_pl9_current_gap_appendix,
|
||||
_high_value_theme_authority_sentence,
|
||||
_kp_conflict_adjudication_sentence,
|
||||
_kp_house_focus_text,
|
||||
_prashna_exact_cusp_boundary_sentence,
|
||||
_render_boundary_packet_summary_line,
|
||||
_section_evidence_boundary,
|
||||
_status_from_report_pack_section,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
from report_render.sections_misc import (
|
||||
_chart_planets_from_positions,
|
||||
_domi_pl9_current_gap_appendix,
|
||||
_high_value_theme_authority_sentence,
|
||||
_kp_conflict_adjudication_sentence,
|
||||
_kp_house_focus_text,
|
||||
_prashna_exact_cusp_boundary_sentence,
|
||||
_render_boundary_packet_summary_line,
|
||||
_section_evidence_boundary,
|
||||
_status_from_report_pack_section,
|
||||
)
|
||||
try:
|
||||
from scripts.report_render.text import (
|
||||
PLANET_LABEL_CN,
|
||||
_append_unique_text,
|
||||
_compose_narrative_paragraph,
|
||||
_display_rashi_from_value,
|
||||
_humanize_annual_highlight_line,
|
||||
_humanize_annual_narrative_highlight,
|
||||
_humanize_domain_title,
|
||||
_humanize_reader_group,
|
||||
_humanize_reader_summary_line,
|
||||
_humanize_reader_token,
|
||||
_humanize_tajika_candidate,
|
||||
_humanize_tajika_motion,
|
||||
_md_cell,
|
||||
_planet_list_text,
|
||||
_render_compact_payload,
|
||||
_sign_text,
|
||||
_value_text,
|
||||
_workbook_readable_label,
|
||||
_workbook_readable_sentence,
|
||||
_workbook_reading_paragraph,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
from report_render.text import (
|
||||
PLANET_LABEL_CN,
|
||||
_append_unique_text,
|
||||
_compose_narrative_paragraph,
|
||||
_display_rashi_from_value,
|
||||
_humanize_annual_highlight_line,
|
||||
_humanize_annual_narrative_highlight,
|
||||
_humanize_domain_title,
|
||||
_humanize_reader_group,
|
||||
_humanize_reader_summary_line,
|
||||
_humanize_reader_token,
|
||||
_humanize_tajika_candidate,
|
||||
_humanize_tajika_motion,
|
||||
_md_cell,
|
||||
_planet_list_text,
|
||||
_render_compact_payload,
|
||||
_sign_text,
|
||||
_value_text,
|
||||
_workbook_readable_label,
|
||||
_workbook_readable_sentence,
|
||||
_workbook_reading_paragraph,
|
||||
)
|
||||
|
||||
try:
|
||||
from calculation_profile_contract import (
|
||||
@@ -3501,173 +3589,7 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
else {}
|
||||
)
|
||||
|
||||
def _unwrap(raw_value):
|
||||
# 兼容 packet 字段的 response_envelope 升级:
|
||||
# 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。
|
||||
if (
|
||||
isinstance(raw_value, dict)
|
||||
and 'result' in raw_value
|
||||
and 'response_envelope' in raw_value
|
||||
):
|
||||
return raw_value.get('result')
|
||||
return raw_value
|
||||
|
||||
def _envelope_list(raw_value):
|
||||
raw_value = _unwrap(raw_value)
|
||||
if isinstance(raw_value, list):
|
||||
return raw_value
|
||||
if isinstance(raw_value, tuple):
|
||||
return list(raw_value)
|
||||
# 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。
|
||||
if isinstance(raw_value, dict) and 'result' in raw_value:
|
||||
nested = raw_value.get('result')
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
if isinstance(nested, tuple):
|
||||
return list(nested)
|
||||
return []
|
||||
|
||||
def _envelope_dict(raw_value):
|
||||
raw_value = _unwrap(raw_value)
|
||||
return raw_value if isinstance(raw_value, dict) else {}
|
||||
|
||||
def _bool_text(value):
|
||||
return '是' if value else '否'
|
||||
|
||||
planet_label_cn = {
|
||||
'Sun': '太阳',
|
||||
'Moon': '月亮',
|
||||
'Mars': '火星',
|
||||
'Mercury': '水星',
|
||||
'Jupiter': '木星',
|
||||
'Venus': '金星',
|
||||
'Saturn': '土星',
|
||||
'Rahu': '北交点',
|
||||
'Ketu': '南交点',
|
||||
}
|
||||
|
||||
def _humanize_reader_token(token: str) -> str:
|
||||
value = str(token or '').strip()
|
||||
if not value:
|
||||
return value
|
||||
if value in SIGNS_CN:
|
||||
return SIGNS_CN[value]
|
||||
if value in planet_label_cn:
|
||||
return planet_label_cn[value]
|
||||
return value
|
||||
|
||||
def _humanize_reader_group(raw_group: str) -> str:
|
||||
seen = set()
|
||||
parts = []
|
||||
for item in str(raw_group or '').split(','):
|
||||
humanized = _humanize_reader_token(item)
|
||||
if humanized and humanized not in seen:
|
||||
seen.add(humanized)
|
||||
parts.append(humanized)
|
||||
return ' / '.join(parts)
|
||||
|
||||
def _humanize_tajika_candidate(name: str) -> str:
|
||||
return {
|
||||
'Ithasala_candidate': '趋近相位候选',
|
||||
'Easarapha_candidate': '分离相位候选',
|
||||
}.get(str(name or ''), '年度相位候选')
|
||||
|
||||
def _humanize_tajika_motion(motion: str) -> str:
|
||||
return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-'))
|
||||
|
||||
def _display_rashi_from_value(raw_value):
|
||||
if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS):
|
||||
return _humanize_reader_token(SIGNS[raw_value])
|
||||
return _humanize_reader_token(raw_value)
|
||||
|
||||
def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str:
|
||||
summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {}
|
||||
bits = [_md_cell(packet.get('packet_mode'))]
|
||||
for key, suffix in summary_pairs:
|
||||
value = summary.get(key)
|
||||
if value not in (None, '', []):
|
||||
bits.append(f"{_md_cell(value)}{suffix}")
|
||||
return f"参考包摘要:{';'.join(bits)}。"
|
||||
|
||||
def _humanize_reader_summary_line(summary_line: str) -> str:
|
||||
summary_line = _unwrap(summary_line)
|
||||
if isinstance(summary_line, dict):
|
||||
summary_line = (
|
||||
summary_line.get('brief')
|
||||
or summary_line.get('text')
|
||||
or summary_line.get('summary')
|
||||
or ''
|
||||
)
|
||||
text = str(summary_line or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。":
|
||||
return "这一年的年度主线已经形成。"
|
||||
if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.":
|
||||
return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。"
|
||||
if text == "Source: dasha_master_pack.interpretation_triggers.":
|
||||
return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。"
|
||||
if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.":
|
||||
return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。"
|
||||
if text == "Tajika Yogas: annual candidate structures visible":
|
||||
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
|
||||
if text == "Sahams: annual sensitive points visible":
|
||||
return "Sahams 当前已经能读到年度敏感点。"
|
||||
match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text)
|
||||
if match:
|
||||
topic = match.group(1).strip()
|
||||
left = _humanize_reader_group(match.group(2))
|
||||
right = _humanize_reader_group(match.group(3))
|
||||
topic_label = {
|
||||
'Muntha': 'Muntha',
|
||||
'Year Lord': 'Year Lord',
|
||||
'Annual Asc': '返照上升',
|
||||
}.get(topic, topic)
|
||||
if left == right:
|
||||
return f"{topic_label} 当前结果指向{left}。"
|
||||
return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。"
|
||||
simple_match = re.match(r'^([^:]+):\s*(.+)$', text)
|
||||
if simple_match:
|
||||
topic = simple_match.group(1).strip()
|
||||
value = _humanize_reader_group(simple_match.group(2))
|
||||
if topic == 'Year Lord' and value:
|
||||
return f"Year Lord 当前优先候选指向{value}。"
|
||||
if topic == 'Muntha' and value:
|
||||
return f"Muntha 当前优先候选指向{value}。"
|
||||
if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower():
|
||||
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
|
||||
if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower():
|
||||
return "Sahams 当前已经能读到年度敏感点。"
|
||||
return text
|
||||
|
||||
def _humanize_annual_highlight_line(highlight: str) -> str:
|
||||
text = str(highlight or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
if text == "Solar Return / Tajika annual shell is available.":
|
||||
return "本年度返照与 Tajika 时间结构已经生成。"
|
||||
if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.":
|
||||
return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。"
|
||||
if text == "The report should read annual structure first, then the evidence appendix.":
|
||||
return "这一层适合从年度结构展开,再回头核对证据附录。"
|
||||
return text
|
||||
|
||||
def _humanize_annual_narrative_highlight(highlight: str) -> str:
|
||||
return _humanize_annual_highlight_line(str(highlight)).rstrip('。')
|
||||
|
||||
def _humanize_domain_title(title: str) -> str:
|
||||
text = str(title or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
mapping = {
|
||||
"Career and public direction": "事业与公众方向",
|
||||
"Wealth and growth opportunities": "财富与增长机会",
|
||||
"Relationship and alliance themes": "关系与合作主题",
|
||||
"Health and vitality caution": "健康与精力提醒",
|
||||
"Risk, delay, and friction markers": "阻力、延迟与摩擦提示",
|
||||
"Timing windows and cross-check needs": "时间窗口与交叉核对重点",
|
||||
}
|
||||
return mapping.get(text, text)
|
||||
planet_label_cn = PLANET_LABEL_CN
|
||||
|
||||
def _degree_text(value):
|
||||
if value in (None, '', [], {}):
|
||||
@@ -3677,12 +3599,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
def _source_dict(*candidates):
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, dict) and candidate:
|
||||
return candidate
|
||||
return {}
|
||||
|
||||
def _render_basic_tables() -> list[str]:
|
||||
birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {}
|
||||
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
|
||||
@@ -3907,113 +3823,11 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
readable = [_segment_sentence(segment_id).rstrip('。') for segment_id in segment_ids]
|
||||
return f"{group_name}包括:{';'.join(readable)}。"
|
||||
|
||||
def _workbook_readable_label(worksheet_id: str) -> str:
|
||||
if worksheet_id == 'birth_and_parameters':
|
||||
return '出生与计算口径'
|
||||
if worksheet_id == 'd1_rasi_bhava':
|
||||
return '本命盘主结构'
|
||||
if worksheet_id == 'strengths_and_scores':
|
||||
return '力量与技术表'
|
||||
if worksheet_id == 'divisional_and_special_charts':
|
||||
return '分盘与特殊点位'
|
||||
if worksheet_id == 'timing_and_predictive_systems':
|
||||
return '时间系统与流年层'
|
||||
if worksheet_id == 'advanced_systems':
|
||||
return '进阶体系'
|
||||
if worksheet_id == 'ai_and_audit':
|
||||
return '审计与证据附录'
|
||||
return '补充工作簿'
|
||||
|
||||
def _workbook_readable_sentence(worksheet_id: str) -> str:
|
||||
if worksheet_id == 'birth_and_parameters':
|
||||
return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。'
|
||||
if worksheet_id == 'd1_rasi_bhava':
|
||||
return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。'
|
||||
if worksheet_id == 'strengths_and_scores':
|
||||
return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。'
|
||||
if worksheet_id == 'divisional_and_special_charts':
|
||||
return '分盘与特殊点位可作为扩展阅读,适合补充看细节。'
|
||||
if worksheet_id == 'timing_and_predictive_systems':
|
||||
return '大运、行运与年度层已经可以形成时间主线。'
|
||||
if worksheet_id == 'advanced_systems':
|
||||
return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。'
|
||||
if worksheet_id == 'ai_and_audit':
|
||||
return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。'
|
||||
return f'{worksheet_id} 已纳入当前报告。'
|
||||
|
||||
def _reader_group_paragraph(group_name: str, segment_ids: list[str]) -> str:
|
||||
if not segment_ids:
|
||||
return f'{group_name}当前暂无稳定内容。'
|
||||
return _segment_group_sentence(group_name, segment_ids)
|
||||
|
||||
def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str:
|
||||
parts = []
|
||||
for worksheet_id in worksheet_ids:
|
||||
sentence = _workbook_readable_sentence(worksheet_id).rstrip('。')
|
||||
parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}")
|
||||
if not parts:
|
||||
return '当前暂无可用工作簿阅读顺序。'
|
||||
return ';'.join(parts) + '。'
|
||||
|
||||
def _md_cell(value) -> str:
|
||||
text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip()
|
||||
return text.replace('|', '/')
|
||||
|
||||
def _render_compact_payload(value, depth: int = 0) -> str:
|
||||
if value in (None, '', [], {}):
|
||||
return '-'
|
||||
if depth >= 2:
|
||||
return _md_cell(type(value).__name__)
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for key in sorted(value, key=str):
|
||||
if key in {
|
||||
'narrative_items',
|
||||
'domain_paragraphs',
|
||||
'response_envelope',
|
||||
'input_hash',
|
||||
'profile_hash',
|
||||
'raw_calculation',
|
||||
'shared_full_report_authority',
|
||||
}:
|
||||
continue
|
||||
parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}")
|
||||
return '; '.join(parts) if parts else '-'
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]]
|
||||
suffix = ' …' if len(value) > 8 else ''
|
||||
return ', '.join(items) + suffix if items else '-'
|
||||
return _md_cell(value)
|
||||
|
||||
def _status_from_report_pack_section(section: dict) -> str:
|
||||
if not isinstance(section, dict):
|
||||
return 'blocked'
|
||||
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
|
||||
if section.get('status'):
|
||||
return str(section.get('status'))
|
||||
if audit.get('status'):
|
||||
return str(audit.get('status'))
|
||||
if section:
|
||||
return 'partial_verified'
|
||||
return 'blocked'
|
||||
|
||||
def _section_evidence_boundary(section: dict) -> str:
|
||||
if not isinstance(section, dict):
|
||||
return 'section_missing'
|
||||
exports = section.get('exports') if isinstance(section.get('exports'), dict) else {}
|
||||
ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {}
|
||||
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
|
||||
boundaries = []
|
||||
if ai_bundle.get('contains_private_pl9_text') is False:
|
||||
boundaries.append('no_private_pl9_text')
|
||||
if ai_bundle.get('claim_policy'):
|
||||
boundaries.append(str(ai_bundle.get('claim_policy')))
|
||||
if audit.get('must_not_claim'):
|
||||
boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3])))
|
||||
if section.get('reason'):
|
||||
boundaries.append(str(section.get('reason')))
|
||||
return '; '.join(boundaries) if boundaries else 'evidence_labeled'
|
||||
|
||||
def _full_report_pack_assembly_section() -> list[str]:
|
||||
if not isinstance(full_report_pack, dict) or not full_report_pack:
|
||||
return []
|
||||
@@ -4071,9 +3885,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
out.append('')
|
||||
return out
|
||||
|
||||
def _domi_pl9_current_gap_appendix() -> list[str]:
|
||||
return []
|
||||
|
||||
def _profile_aware_benchmark_appendix() -> list[str]:
|
||||
dashboard_path = Path(ROOT_DIR) / 'references/oracle/profile_aware_benchmark_boundary_dashboard_2026_08_27.json'
|
||||
if not dashboard_path.exists():
|
||||
@@ -4401,21 +4212,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
out.append('')
|
||||
return out
|
||||
|
||||
def _chart_planets_from_positions(positions: dict) -> dict:
|
||||
chart_planets = {}
|
||||
if not isinstance(positions, dict):
|
||||
return chart_planets
|
||||
for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
|
||||
planet = positions.get(planet_name)
|
||||
if not isinstance(planet, dict):
|
||||
continue
|
||||
sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name')
|
||||
if not sign:
|
||||
continue
|
||||
degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0)))
|
||||
chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0}
|
||||
return chart_planets
|
||||
|
||||
def _render_south_chart(
|
||||
positions: dict,
|
||||
ascendant_row: dict,
|
||||
@@ -5385,30 +5181,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
return "UL 在待确认位置、Pranapada 在待确认位置,但关系主题仍建议把制度承诺层与气质投射层分开看。"
|
||||
return None
|
||||
|
||||
def _kp_house_focus_text(value) -> str:
|
||||
house_label_map = {
|
||||
2: '收入与资源',
|
||||
5: '机会与投入',
|
||||
6: '职责与压力',
|
||||
7: '合作与伴侣',
|
||||
9: '运气与扩张',
|
||||
10: '事业与位置',
|
||||
11: '回报与支持',
|
||||
}
|
||||
parts = []
|
||||
if isinstance(value, (list, tuple)):
|
||||
raw_parts = [str(item).strip() for item in value if str(item).strip()]
|
||||
else:
|
||||
raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()]
|
||||
for part in raw_parts:
|
||||
try:
|
||||
house_no = int(part)
|
||||
except ValueError:
|
||||
parts.append(part)
|
||||
continue
|
||||
parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]")
|
||||
return '、'.join(parts) if parts else '-'
|
||||
|
||||
def _graha_padas_domain_bridge(domain: str) -> str | None:
|
||||
graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {}
|
||||
graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {}
|
||||
@@ -6230,41 +6002,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
segments.append('这些层当前仍只作为 ' + ' / '.join(statuses) + ' 的辅助阅读,不直接升级为主题主结论')
|
||||
return '主题支持补充:' + ';'.join(segments) + '。'
|
||||
|
||||
def _high_value_theme_authority_sentence(domain: str) -> str | None:
|
||||
domain_label_map = {
|
||||
'career': '事业',
|
||||
'wealth': '财务',
|
||||
'relationship': '关系',
|
||||
}
|
||||
action_map = {
|
||||
'career': '优先排序事业推进顺序、外部舞台和月份节奏',
|
||||
'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序',
|
||||
'relationship': '优先排序合作推进、联结温度和关系节奏',
|
||||
}
|
||||
return (
|
||||
"主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与"
|
||||
+ f"{domain_label_map.get(domain, domain)}章主结论,"
|
||||
+ f"用来{action_map.get(domain, '补强主题排序')};"
|
||||
+ "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。"
|
||||
)
|
||||
|
||||
def _kp_conflict_adjudication_sentence(domain: str) -> str:
|
||||
domain_label_map = {
|
||||
'career': '事业',
|
||||
'wealth': '财务',
|
||||
'relationship': '关系',
|
||||
}
|
||||
focus_map = {
|
||||
'career': '外部舞台、职位变化和项目推进',
|
||||
'wealth': '资源承接、现金流和回收节奏',
|
||||
'relationship': '合作推进、联结温度和承诺节奏',
|
||||
}
|
||||
return (
|
||||
f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。"
|
||||
f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。"
|
||||
f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。"
|
||||
)
|
||||
|
||||
def _patyayini_reader_bridge() -> list[str]:
|
||||
if not replay_patyayini_rows:
|
||||
return []
|
||||
@@ -6305,12 +6042,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
)
|
||||
return out
|
||||
|
||||
def _prashna_exact_cusp_boundary_sentence() -> str | None:
|
||||
return (
|
||||
"如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;"
|
||||
"Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。"
|
||||
)
|
||||
|
||||
def _reader_unexported_high_value_layers_section() -> list[str]:
|
||||
divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {}
|
||||
points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {}
|
||||
@@ -6394,12 +6125,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
except (TypeError, ValueError):
|
||||
return _md_cell(value)
|
||||
|
||||
def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str:
|
||||
if not isinstance(row, dict):
|
||||
return '-'
|
||||
sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key)
|
||||
return _md_cell(sign)
|
||||
|
||||
def _pl9_core_structure_tables() -> list[str]:
|
||||
birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {}
|
||||
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
|
||||
@@ -6508,12 +6233,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
out.append('')
|
||||
return out
|
||||
|
||||
def _value_text(*values):
|
||||
for value in values:
|
||||
if value not in (None, '', [], {}):
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
ai_audit_sheet = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {}
|
||||
ai_summary = ai_audit_sheet.get('summary') if isinstance(ai_audit_sheet.get('summary'), dict) else {}
|
||||
guided_topics = ai_summary.get('guided_topics') if isinstance(ai_summary.get('guided_topics'), list) else []
|
||||
@@ -6681,20 +6400,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
return ';'.join(bits) + '。'
|
||||
return None
|
||||
|
||||
def _compose_narrative_paragraph(*parts: str | None) -> str | None:
|
||||
cleaned = []
|
||||
for part in parts:
|
||||
text = str(part or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
text = text.rstrip()
|
||||
if text.endswith('。'):
|
||||
text = text[:-1]
|
||||
cleaned.append(text)
|
||||
if not cleaned:
|
||||
return None
|
||||
return ';'.join(cleaned) + '。'
|
||||
|
||||
dasha_domain_rows = {}
|
||||
domain_paragraphs = dasha_interpretation_pack.get('domain_paragraphs') if isinstance(dasha_interpretation_pack.get('domain_paragraphs'), list) else []
|
||||
for row in domain_paragraphs:
|
||||
@@ -6724,15 +6429,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
]
|
||||
special_lagnas_layer = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {}
|
||||
|
||||
def _has_executed_payload(value) -> bool:
|
||||
if value in (None, '', [], {}):
|
||||
return False
|
||||
if isinstance(value, dict):
|
||||
status = str(value.get('status') or value.get('execution_status') or '').strip().lower()
|
||||
if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _has_varga_code(varga_code: str) -> bool:
|
||||
prefix = f'{varga_code}_'
|
||||
for layer in varga_layers:
|
||||
@@ -6955,20 +6651,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
return None
|
||||
return ';'.join(summary_bits) + '。'
|
||||
|
||||
def _planet_list_text(items: list[str], limit: int = 3) -> str | None:
|
||||
names = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
humanized = _humanize_reader_token(item)
|
||||
if humanized and humanized not in seen:
|
||||
seen.add(humanized)
|
||||
names.append(humanized)
|
||||
if len(names) >= limit:
|
||||
break
|
||||
if not names:
|
||||
return None
|
||||
return '、'.join(names)
|
||||
|
||||
def _functional_layer_sentence() -> str | None:
|
||||
if functional_layer.get('status') != 'used':
|
||||
return None
|
||||
@@ -7368,11 +7050,6 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
annual_evidence_appendix = _envelope_dict(annual_report_sections.get('evidence_appendix'))
|
||||
annual_field_briefs = _envelope_dict(annual_evidence_appendix.get('field_briefs'))
|
||||
|
||||
def _append_unique_text(items: list[str], value: str) -> None:
|
||||
text = str(value or '').strip().rstrip('。')
|
||||
if text and text not in items:
|
||||
items.append(text)
|
||||
|
||||
def _reader_executive_summary() -> dict:
|
||||
ai_audit = packet.get('ai_and_audit') if isinstance(packet.get('ai_and_audit'), dict) else {}
|
||||
worksheet_ai = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
|
||||
|
||||
def _unwrap(raw_value):
|
||||
# 兼容 packet 字段的 response_envelope 升级:
|
||||
# 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。
|
||||
if (
|
||||
isinstance(raw_value, dict)
|
||||
and 'result' in raw_value
|
||||
and 'response_envelope' in raw_value
|
||||
):
|
||||
return raw_value.get('result')
|
||||
return raw_value
|
||||
|
||||
def _envelope_list(raw_value):
|
||||
raw_value = _unwrap(raw_value)
|
||||
if isinstance(raw_value, list):
|
||||
return raw_value
|
||||
if isinstance(raw_value, tuple):
|
||||
return list(raw_value)
|
||||
# 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。
|
||||
if isinstance(raw_value, dict) and 'result' in raw_value:
|
||||
nested = raw_value.get('result')
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
if isinstance(nested, tuple):
|
||||
return list(nested)
|
||||
return []
|
||||
|
||||
def _envelope_dict(raw_value):
|
||||
raw_value = _unwrap(raw_value)
|
||||
return raw_value if isinstance(raw_value, dict) else {}
|
||||
|
||||
def _bool_text(value):
|
||||
return '是' if value else '否'
|
||||
|
||||
def _source_dict(*candidates):
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, dict) and candidate:
|
||||
return candidate
|
||||
return {}
|
||||
|
||||
def _has_executed_payload(value) -> bool:
|
||||
if value in (None, '', [], {}):
|
||||
return False
|
||||
if isinstance(value, dict):
|
||||
status = str(value.get('status') or value.get('execution_status') or '').strip().lower()
|
||||
if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
|
||||
|
||||
try:
|
||||
from scripts.report_render.text import _md_cell
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
from report_render.text import _md_cell
|
||||
|
||||
def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str:
|
||||
summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {}
|
||||
bits = [_md_cell(packet.get('packet_mode'))]
|
||||
for key, suffix in summary_pairs:
|
||||
value = summary.get(key)
|
||||
if value not in (None, '', []):
|
||||
bits.append(f"{_md_cell(value)}{suffix}")
|
||||
return f"参考包摘要:{';'.join(bits)}。"
|
||||
|
||||
def _status_from_report_pack_section(section: dict) -> str:
|
||||
if not isinstance(section, dict):
|
||||
return 'blocked'
|
||||
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
|
||||
if section.get('status'):
|
||||
return str(section.get('status'))
|
||||
if audit.get('status'):
|
||||
return str(audit.get('status'))
|
||||
if section:
|
||||
return 'partial_verified'
|
||||
return 'blocked'
|
||||
|
||||
def _section_evidence_boundary(section: dict) -> str:
|
||||
if not isinstance(section, dict):
|
||||
return 'section_missing'
|
||||
exports = section.get('exports') if isinstance(section.get('exports'), dict) else {}
|
||||
ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {}
|
||||
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
|
||||
boundaries = []
|
||||
if ai_bundle.get('contains_private_pl9_text') is False:
|
||||
boundaries.append('no_private_pl9_text')
|
||||
if ai_bundle.get('claim_policy'):
|
||||
boundaries.append(str(ai_bundle.get('claim_policy')))
|
||||
if audit.get('must_not_claim'):
|
||||
boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3])))
|
||||
if section.get('reason'):
|
||||
boundaries.append(str(section.get('reason')))
|
||||
return '; '.join(boundaries) if boundaries else 'evidence_labeled'
|
||||
|
||||
def _domi_pl9_current_gap_appendix() -> list[str]:
|
||||
return []
|
||||
|
||||
def _chart_planets_from_positions(positions: dict) -> dict:
|
||||
chart_planets = {}
|
||||
if not isinstance(positions, dict):
|
||||
return chart_planets
|
||||
for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
|
||||
planet = positions.get(planet_name)
|
||||
if not isinstance(planet, dict):
|
||||
continue
|
||||
sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name')
|
||||
if not sign:
|
||||
continue
|
||||
degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0)))
|
||||
chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0}
|
||||
return chart_planets
|
||||
|
||||
def _kp_house_focus_text(value) -> str:
|
||||
house_label_map = {
|
||||
2: '收入与资源',
|
||||
5: '机会与投入',
|
||||
6: '职责与压力',
|
||||
7: '合作与伴侣',
|
||||
9: '运气与扩张',
|
||||
10: '事业与位置',
|
||||
11: '回报与支持',
|
||||
}
|
||||
parts = []
|
||||
if isinstance(value, (list, tuple)):
|
||||
raw_parts = [str(item).strip() for item in value if str(item).strip()]
|
||||
else:
|
||||
raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()]
|
||||
for part in raw_parts:
|
||||
try:
|
||||
house_no = int(part)
|
||||
except ValueError:
|
||||
parts.append(part)
|
||||
continue
|
||||
parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]")
|
||||
return '、'.join(parts) if parts else '-'
|
||||
|
||||
def _high_value_theme_authority_sentence(domain: str) -> str | None:
|
||||
domain_label_map = {
|
||||
'career': '事业',
|
||||
'wealth': '财务',
|
||||
'relationship': '关系',
|
||||
}
|
||||
action_map = {
|
||||
'career': '优先排序事业推进顺序、外部舞台和月份节奏',
|
||||
'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序',
|
||||
'relationship': '优先排序合作推进、联结温度和关系节奏',
|
||||
}
|
||||
return (
|
||||
"主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与"
|
||||
+ f"{domain_label_map.get(domain, domain)}章主结论,"
|
||||
+ f"用来{action_map.get(domain, '补强主题排序')};"
|
||||
+ "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。"
|
||||
)
|
||||
|
||||
def _kp_conflict_adjudication_sentence(domain: str) -> str:
|
||||
domain_label_map = {
|
||||
'career': '事业',
|
||||
'wealth': '财务',
|
||||
'relationship': '关系',
|
||||
}
|
||||
focus_map = {
|
||||
'career': '外部舞台、职位变化和项目推进',
|
||||
'wealth': '资源承接、现金流和回收节奏',
|
||||
'relationship': '合作推进、联结温度和承诺节奏',
|
||||
}
|
||||
return (
|
||||
f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。"
|
||||
f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。"
|
||||
f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。"
|
||||
)
|
||||
|
||||
def _prashna_exact_cusp_boundary_sentence() -> str | None:
|
||||
return (
|
||||
"如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;"
|
||||
"Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。"
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
|
||||
|
||||
import re
|
||||
|
||||
try:
|
||||
from scripts.report_render.envelope import _unwrap
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
from report_render.envelope import _unwrap
|
||||
|
||||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||||
SIGNS_CN = {'Aries': '白羊座', 'Taurus': '金牛座', 'Gemini': '双子座', 'Cancer': '巨蟹座', 'Leo': '狮子座', 'Virgo': '处女座', 'Libra': '天秤座', 'Scorpio': '天蝎座', 'Sagittarius': '射手座', 'Capricorn': '摩羯座', 'Aquarius': '水瓶座', 'Pisces': '双鱼座'}
|
||||
|
||||
PLANET_LABEL_CN = {
|
||||
'Sun': '太阳',
|
||||
'Moon': '月亮',
|
||||
'Mars': '火星',
|
||||
'Mercury': '水星',
|
||||
'Jupiter': '木星',
|
||||
'Venus': '金星',
|
||||
'Saturn': '土星',
|
||||
'Rahu': '北交点',
|
||||
'Ketu': '南交点',
|
||||
}
|
||||
|
||||
def _humanize_reader_token(token: str) -> str:
|
||||
value = str(token or '').strip()
|
||||
if not value:
|
||||
return value
|
||||
if value in SIGNS_CN:
|
||||
return SIGNS_CN[value]
|
||||
if value in PLANET_LABEL_CN:
|
||||
return PLANET_LABEL_CN[value]
|
||||
return value
|
||||
|
||||
def _humanize_reader_group(raw_group: str) -> str:
|
||||
seen = set()
|
||||
parts = []
|
||||
for item in str(raw_group or '').split(','):
|
||||
humanized = _humanize_reader_token(item)
|
||||
if humanized and humanized not in seen:
|
||||
seen.add(humanized)
|
||||
parts.append(humanized)
|
||||
return ' / '.join(parts)
|
||||
|
||||
def _humanize_tajika_candidate(name: str) -> str:
|
||||
return {
|
||||
'Ithasala_candidate': '趋近相位候选',
|
||||
'Easarapha_candidate': '分离相位候选',
|
||||
}.get(str(name or ''), '年度相位候选')
|
||||
|
||||
def _humanize_tajika_motion(motion: str) -> str:
|
||||
return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-'))
|
||||
|
||||
def _display_rashi_from_value(raw_value):
|
||||
if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS):
|
||||
return _humanize_reader_token(SIGNS[raw_value])
|
||||
return _humanize_reader_token(raw_value)
|
||||
|
||||
def _humanize_reader_summary_line(summary_line: str) -> str:
|
||||
summary_line = _unwrap(summary_line)
|
||||
if isinstance(summary_line, dict):
|
||||
summary_line = (
|
||||
summary_line.get('brief')
|
||||
or summary_line.get('text')
|
||||
or summary_line.get('summary')
|
||||
or ''
|
||||
)
|
||||
text = str(summary_line or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。":
|
||||
return "这一年的年度主线已经形成。"
|
||||
if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.":
|
||||
return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。"
|
||||
if text == "Source: dasha_master_pack.interpretation_triggers.":
|
||||
return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。"
|
||||
if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.":
|
||||
return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。"
|
||||
if text == "Tajika Yogas: annual candidate structures visible":
|
||||
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
|
||||
if text == "Sahams: annual sensitive points visible":
|
||||
return "Sahams 当前已经能读到年度敏感点。"
|
||||
match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text)
|
||||
if match:
|
||||
topic = match.group(1).strip()
|
||||
left = _humanize_reader_group(match.group(2))
|
||||
right = _humanize_reader_group(match.group(3))
|
||||
topic_label = {
|
||||
'Muntha': 'Muntha',
|
||||
'Year Lord': 'Year Lord',
|
||||
'Annual Asc': '返照上升',
|
||||
}.get(topic, topic)
|
||||
if left == right:
|
||||
return f"{topic_label} 当前结果指向{left}。"
|
||||
return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。"
|
||||
simple_match = re.match(r'^([^:]+):\s*(.+)$', text)
|
||||
if simple_match:
|
||||
topic = simple_match.group(1).strip()
|
||||
value = _humanize_reader_group(simple_match.group(2))
|
||||
if topic == 'Year Lord' and value:
|
||||
return f"Year Lord 当前优先候选指向{value}。"
|
||||
if topic == 'Muntha' and value:
|
||||
return f"Muntha 当前优先候选指向{value}。"
|
||||
if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower():
|
||||
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
|
||||
if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower():
|
||||
return "Sahams 当前已经能读到年度敏感点。"
|
||||
return text
|
||||
|
||||
def _humanize_annual_highlight_line(highlight: str) -> str:
|
||||
text = str(highlight or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
if text == "Solar Return / Tajika annual shell is available.":
|
||||
return "本年度返照与 Tajika 时间结构已经生成。"
|
||||
if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.":
|
||||
return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。"
|
||||
if text == "The report should read annual structure first, then the evidence appendix.":
|
||||
return "这一层适合从年度结构展开,再回头核对证据附录。"
|
||||
return text
|
||||
|
||||
def _humanize_annual_narrative_highlight(highlight: str) -> str:
|
||||
return _humanize_annual_highlight_line(str(highlight)).rstrip('。')
|
||||
|
||||
def _humanize_domain_title(title: str) -> str:
|
||||
text = str(title or '').strip()
|
||||
if not text:
|
||||
return text
|
||||
mapping = {
|
||||
"Career and public direction": "事业与公众方向",
|
||||
"Wealth and growth opportunities": "财富与增长机会",
|
||||
"Relationship and alliance themes": "关系与合作主题",
|
||||
"Health and vitality caution": "健康与精力提醒",
|
||||
"Risk, delay, and friction markers": "阻力、延迟与摩擦提示",
|
||||
"Timing windows and cross-check needs": "时间窗口与交叉核对重点",
|
||||
}
|
||||
return mapping.get(text, text)
|
||||
|
||||
def _workbook_readable_label(worksheet_id: str) -> str:
|
||||
if worksheet_id == 'birth_and_parameters':
|
||||
return '出生与计算口径'
|
||||
if worksheet_id == 'd1_rasi_bhava':
|
||||
return '本命盘主结构'
|
||||
if worksheet_id == 'strengths_and_scores':
|
||||
return '力量与技术表'
|
||||
if worksheet_id == 'divisional_and_special_charts':
|
||||
return '分盘与特殊点位'
|
||||
if worksheet_id == 'timing_and_predictive_systems':
|
||||
return '时间系统与流年层'
|
||||
if worksheet_id == 'advanced_systems':
|
||||
return '进阶体系'
|
||||
if worksheet_id == 'ai_and_audit':
|
||||
return '审计与证据附录'
|
||||
return '补充工作簿'
|
||||
|
||||
def _workbook_readable_sentence(worksheet_id: str) -> str:
|
||||
if worksheet_id == 'birth_and_parameters':
|
||||
return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。'
|
||||
if worksheet_id == 'd1_rasi_bhava':
|
||||
return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。'
|
||||
if worksheet_id == 'strengths_and_scores':
|
||||
return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。'
|
||||
if worksheet_id == 'divisional_and_special_charts':
|
||||
return '分盘与特殊点位可作为扩展阅读,适合补充看细节。'
|
||||
if worksheet_id == 'timing_and_predictive_systems':
|
||||
return '大运、行运与年度层已经可以形成时间主线。'
|
||||
if worksheet_id == 'advanced_systems':
|
||||
return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。'
|
||||
if worksheet_id == 'ai_and_audit':
|
||||
return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。'
|
||||
return f'{worksheet_id} 已纳入当前报告。'
|
||||
|
||||
def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str:
|
||||
parts = []
|
||||
for worksheet_id in worksheet_ids:
|
||||
sentence = _workbook_readable_sentence(worksheet_id).rstrip('。')
|
||||
parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}")
|
||||
if not parts:
|
||||
return '当前暂无可用工作簿阅读顺序。'
|
||||
return ';'.join(parts) + '。'
|
||||
|
||||
def _md_cell(value) -> str:
|
||||
text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip()
|
||||
return text.replace('|', '/')
|
||||
|
||||
def _render_compact_payload(value, depth: int = 0) -> str:
|
||||
if value in (None, '', [], {}):
|
||||
return '-'
|
||||
if depth >= 2:
|
||||
return _md_cell(type(value).__name__)
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for key in sorted(value, key=str):
|
||||
if key in {
|
||||
'narrative_items',
|
||||
'domain_paragraphs',
|
||||
'response_envelope',
|
||||
'input_hash',
|
||||
'profile_hash',
|
||||
'raw_calculation',
|
||||
'shared_full_report_authority',
|
||||
}:
|
||||
continue
|
||||
parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}")
|
||||
return '; '.join(parts) if parts else '-'
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]]
|
||||
suffix = ' …' if len(value) > 8 else ''
|
||||
return ', '.join(items) + suffix if items else '-'
|
||||
return _md_cell(value)
|
||||
|
||||
def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str:
|
||||
if not isinstance(row, dict):
|
||||
return '-'
|
||||
sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key)
|
||||
return _md_cell(sign)
|
||||
|
||||
def _value_text(*values):
|
||||
for value in values:
|
||||
if value not in (None, '', [], {}):
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
def _compose_narrative_paragraph(*parts: str | None) -> str | None:
|
||||
cleaned = []
|
||||
for part in parts:
|
||||
text = str(part or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
text = text.rstrip()
|
||||
if text.endswith('。'):
|
||||
text = text[:-1]
|
||||
cleaned.append(text)
|
||||
if not cleaned:
|
||||
return None
|
||||
return ';'.join(cleaned) + '。'
|
||||
|
||||
def _planet_list_text(items: list[str], limit: int = 3) -> str | None:
|
||||
names = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
humanized = _humanize_reader_token(item)
|
||||
if humanized and humanized not in seen:
|
||||
seen.add(humanized)
|
||||
names.append(humanized)
|
||||
if len(names) >= limit:
|
||||
break
|
||||
if not names:
|
||||
return None
|
||||
return '、'.join(names)
|
||||
|
||||
def _append_unique_text(items: list[str], value: str) -> None:
|
||||
text = str(value or '').strip().rstrip('。')
|
||||
if text and text not in items:
|
||||
items.append(text)
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-compare phase-1 report rendering against a baseline worktree.
|
||||
|
||||
The current tree builds real-engine packets and pickles them outside the repo.
|
||||
A baseline checkout renders those same packets. Baseline-vs-baseline runs first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRODUCTION_BODY = {
|
||||
"format": "markdown",
|
||||
"edition": "full_data",
|
||||
"include_fact_tables": True,
|
||||
"languages": ["zh", "en"],
|
||||
}
|
||||
FOURTH = {
|
||||
"name": "虚构丁",
|
||||
"year": 1990,
|
||||
"month": 1,
|
||||
"day": 1,
|
||||
"hour": 12,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": 28.6139,
|
||||
"lon": 77.2090,
|
||||
"tz": 5.5,
|
||||
"node_mode": "mean",
|
||||
"ayanamsa": "lahiri",
|
||||
"today": "2026-10-13",
|
||||
"transit_date": "2026-10-13",
|
||||
"target_year": 2026,
|
||||
"age": 36,
|
||||
"birth_time_accuracy": "confirmed",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_hashseed() -> None:
|
||||
if os.environ.get("PYTHONHASHSEED") == "0":
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env["PYTHONHASHSEED"] = "0"
|
||||
raise SystemExit(subprocess.call([sys.executable, *sys.argv], env=env))
|
||||
|
||||
|
||||
def _activate(root: Path) -> None:
|
||||
root = root.resolve()
|
||||
script_dir = str(Path(__file__).resolve().parent)
|
||||
while script_dir in sys.path:
|
||||
sys.path.remove(script_dir)
|
||||
folders = [str(root / "jyotish_vedic"), str(root), str(root / "scripts")]
|
||||
for folder in folders:
|
||||
if not Path(folder).exists():
|
||||
continue
|
||||
while folder in sys.path:
|
||||
sys.path.remove(folder)
|
||||
sys.path.insert(0, folder)
|
||||
|
||||
|
||||
def _mask_pyjhora() -> None:
|
||||
import importlib
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="nojhora-"))
|
||||
package = root / "jhora"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8")
|
||||
sys.path.insert(0, str(root))
|
||||
prior = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "")
|
||||
for name in list(sys.modules):
|
||||
if name == "jhora" or name.startswith("jhora."):
|
||||
del sys.modules[name]
|
||||
importlib.invalidate_caches()
|
||||
|
||||
|
||||
def _sha(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _freeze_clocks() -> None:
|
||||
import datetime as dt_mod
|
||||
|
||||
real = dt_mod.datetime
|
||||
fixed_utc = real(2026, 10, 13, 0, 0, 0)
|
||||
fixed_local = real(2026, 10, 13, 8, 0, 0)
|
||||
|
||||
class Frozen(real):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
if tz is not None:
|
||||
return fixed_utc.replace(tzinfo=tz)
|
||||
return fixed_local
|
||||
|
||||
@classmethod
|
||||
def utcnow(cls):
|
||||
return fixed_utc
|
||||
|
||||
for module in list(sys.modules.values()):
|
||||
if module is None:
|
||||
continue
|
||||
current = getattr(module, "datetime", None)
|
||||
if isinstance(current, type) and current.__name__ == "datetime":
|
||||
module.datetime = Frozen
|
||||
|
||||
|
||||
def _cases() -> list[tuple[str, dict]]:
|
||||
from tests.test_report_english_edition import CASES
|
||||
|
||||
ordered = [(name, dict(CASES[name])) for name in ("day", "night", "south")]
|
||||
ordered.append(("fourth", dict(FOURTH)))
|
||||
return ordered
|
||||
|
||||
|
||||
def _subset_pack_id(packet: dict) -> str | None:
|
||||
manifest = packet.get("report_pack_manifest") if isinstance(packet, dict) else None
|
||||
packs = manifest.get("packs") if isinstance(manifest, dict) else None
|
||||
if not isinstance(packs, list):
|
||||
return None
|
||||
for item in packs:
|
||||
if isinstance(item, dict) and item.get("id") and item.get("id") != "full":
|
||||
return str(item["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _build_records() -> list[dict]:
|
||||
from scripts.jyotish_engine import (
|
||||
_apply_pl9_pack_selection,
|
||||
build_professional_report_reference_packet,
|
||||
cmd_full_reading,
|
||||
)
|
||||
from scripts.professional_report_reference import _export_args
|
||||
|
||||
records = []
|
||||
for name, case in _cases():
|
||||
print(f"build {name}", file=sys.stderr, flush=True)
|
||||
reading = cmd_full_reading(SimpleNamespace(**case))
|
||||
packet = build_professional_report_reference_packet(reading, _export_args(case), [])
|
||||
records.append({"id": f"{name}/reference", "kind": "pl9", "payload": packet})
|
||||
stripped = copy.deepcopy(packet)
|
||||
if isinstance(stripped, dict):
|
||||
stripped.pop("personal_report_producer", None)
|
||||
records.append({"id": f"{name}/no-producer", "kind": "pl9", "payload": stripped})
|
||||
pack_id = _subset_pack_id(packet if isinstance(packet, dict) else {})
|
||||
if pack_id:
|
||||
selected = _apply_pl9_pack_selection(copy.deepcopy(packet), [pack_id])
|
||||
records.append({"id": f"{name}/pack/{pack_id}", "kind": "pl9", "payload": selected})
|
||||
else:
|
||||
print(f"skip pack {name}: no non-full pack", file=sys.stderr)
|
||||
records.append({"id": f"{name}/full-data-zh", "kind": "full", "payload": packet})
|
||||
english = copy.deepcopy(packet)
|
||||
if isinstance(english, dict):
|
||||
english["report_language"] = "en"
|
||||
records.append({"id": f"{name}/full-data-en", "kind": "full", "payload": english})
|
||||
records.append({
|
||||
"id": f"{name}/api-production",
|
||||
"kind": "api",
|
||||
"payload": {"birth": case, "reading": reading},
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def _render_text(kind: str, payload) -> str:
|
||||
if kind == "pl9":
|
||||
from scripts.jyotish_engine import render_pl9_markdown
|
||||
|
||||
return render_pl9_markdown(copy.deepcopy(payload))
|
||||
if kind == "full":
|
||||
from scripts.pl9_full_data_export import render_full_data_markdown
|
||||
|
||||
return render_full_data_markdown(copy.deepcopy(payload))
|
||||
if kind == "api":
|
||||
from scripts.professional_report_reference import build_professional_report_reference
|
||||
|
||||
birth = payload["birth"]
|
||||
reading = copy.deepcopy(payload["reading"])
|
||||
|
||||
class Handler:
|
||||
def _high_rigor_birth_payload(self, _body):
|
||||
return dict(birth)
|
||||
|
||||
def _compute_full_reading_for_thematic(self, _birth):
|
||||
return reading
|
||||
|
||||
response = build_professional_report_reference(Handler(), dict(PRODUCTION_BODY))
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
raise RuntimeError(f"unknown kind {kind}")
|
||||
|
||||
|
||||
def _render_records(records: list[dict]) -> list[dict]:
|
||||
import scripts.jyotish_engine # noqa: F401
|
||||
import scripts.pl9_full_data_export # noqa: F401
|
||||
import scripts.professional_report_reference # noqa: F401
|
||||
|
||||
_freeze_clocks()
|
||||
rendered = []
|
||||
for record in records:
|
||||
text = _render_text(record["kind"], record["payload"])
|
||||
if not isinstance(text, str):
|
||||
raise RuntimeError(f"{record['id']} did not render to text")
|
||||
rendered.append({"id": record["id"], "sha": _sha(text), "text": text})
|
||||
return rendered
|
||||
|
||||
|
||||
def _diff_context(left: str, right: str) -> str:
|
||||
left_lines = left.splitlines() or [left]
|
||||
right_lines = right.splitlines() or [right]
|
||||
limit = min(len(left_lines), len(right_lines))
|
||||
index = next((i for i in range(limit) if left_lines[i] != right_lines[i]), limit)
|
||||
start = max(0, index - 2)
|
||||
rows = []
|
||||
for line_no in range(start, start + 5):
|
||||
sample = left_lines[line_no] if line_no < len(left_lines) else ""
|
||||
other = right_lines[line_no] if line_no < len(right_lines) else ""
|
||||
if sample == other and line_no != index:
|
||||
rows.append(f" {line_no + 1}: {sample}")
|
||||
continue
|
||||
cut = _window(sample, other)
|
||||
rows.append(f"- {line_no + 1}: {cut[0]}")
|
||||
rows.append(f"+ {line_no + 1}: {cut[1]}")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _window(left: str, right: str) -> tuple[str, str]:
|
||||
if len(left) <= 240 and len(right) <= 240:
|
||||
return left, right
|
||||
limit = min(len(left), len(right))
|
||||
index = next((i for i in range(limit) if left[i] != right[i]), limit)
|
||||
start = max(0, index - 80)
|
||||
stop = index + 80
|
||||
return left[start:stop], right[start:stop]
|
||||
|
||||
|
||||
def _invoke_baseline(baseline: Path, pickle_path: Path, result_path: Path, mask: bool) -> subprocess.CompletedProcess[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
"--render-only",
|
||||
"--pickle",
|
||||
str(pickle_path),
|
||||
"--engine-root",
|
||||
str(baseline),
|
||||
"--result",
|
||||
str(result_path),
|
||||
]
|
||||
if mask:
|
||||
command.append("--mask-pyjhora")
|
||||
env = dict(os.environ)
|
||||
env["PYTHONHASHSEED"] = "0"
|
||||
env["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
env["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=str(baseline),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _load_result(path: Path) -> list[dict]:
|
||||
return pickle.loads(path.read_bytes())
|
||||
|
||||
|
||||
def _print_shas(rows: list[dict]) -> None:
|
||||
for row in rows:
|
||||
print(f"{row['id']}\t{row['sha']}")
|
||||
|
||||
|
||||
def _compare(label: str, left: list[dict], right: list[dict]) -> int:
|
||||
if [row["id"] for row in left] != [row["id"] for row in right]:
|
||||
print(f"{label}: record ids differ", file=sys.stderr)
|
||||
return 1
|
||||
failed = 0
|
||||
for lhs, rhs in zip(left, right):
|
||||
if lhs["sha"] == rhs["sha"]:
|
||||
continue
|
||||
failed += 1
|
||||
print(f"{label} mismatch {lhs['id']} {lhs['sha']} vs {rhs['sha']}", file=sys.stderr)
|
||||
print(_diff_context(lhs["text"], rhs["text"]), file=sys.stderr)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def _render_only(args: argparse.Namespace) -> int:
|
||||
if args.mask_pyjhora:
|
||||
_mask_pyjhora()
|
||||
_activate(Path(args.engine_root))
|
||||
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
records = pickle.loads(Path(args.pickle).read_bytes())
|
||||
rendered = _render_records(records)
|
||||
Path(args.result).write_bytes(pickle.dumps(rendered, protocol=pickle.HIGHEST_PROTOCOL))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_ensure_hashseed()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--baseline", type=Path)
|
||||
parser.add_argument("--mask-pyjhora", action="store_true")
|
||||
parser.add_argument("--render-only", action="store_true")
|
||||
parser.add_argument("--pickle", type=Path)
|
||||
parser.add_argument("--engine-root", type=Path)
|
||||
parser.add_argument("--result", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.render_only:
|
||||
return _render_only(args)
|
||||
if args.baseline is None:
|
||||
print("missing --baseline", file=sys.stderr)
|
||||
return 2
|
||||
if args.mask_pyjhora:
|
||||
_mask_pyjhora()
|
||||
_activate(ROOT)
|
||||
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
records = _build_records()
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pl9-render-split-"))
|
||||
try:
|
||||
pickle_path = temp_dir / "packets.pkl"
|
||||
pickle_path.write_bytes(pickle.dumps(records, protocol=pickle.HIGHEST_PROTOCOL))
|
||||
first_path = temp_dir / "baseline-a.pkl"
|
||||
second_path = temp_dir / "baseline-b.pkl"
|
||||
first = _invoke_baseline(args.baseline, pickle_path, first_path, args.mask_pyjhora)
|
||||
second = _invoke_baseline(args.baseline, pickle_path, second_path, args.mask_pyjhora)
|
||||
if first.returncode != 0 or second.returncode != 0:
|
||||
print(first.stderr[-4000:], file=sys.stderr)
|
||||
print(second.stderr[-4000:], file=sys.stderr)
|
||||
print(first.stdout[-1000:], file=sys.stderr)
|
||||
return first.returncode or second.returncode or 1
|
||||
baseline_a = _load_result(first_path)
|
||||
baseline_b = _load_result(second_path)
|
||||
if _compare("baseline-vs-baseline", baseline_a, baseline_b) != 0:
|
||||
print("基线对基线两次 sha 不同,存在非确定性,停止", file=sys.stderr)
|
||||
return 2
|
||||
current = _render_records(pickle.loads(pickle_path.read_bytes()))
|
||||
if _compare("current-vs-baseline", current, baseline_a) != 0:
|
||||
return 1
|
||||
_print_shas(current)
|
||||
print(f"ok items={len(current)} masked={int(args.mask_pyjhora)}")
|
||||
return 0
|
||||
finally:
|
||||
for child in temp_dir.glob("*"):
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The chart API must keep the ascendant nakshatra the engine already computed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
TESTS = ROOT / "tests"
|
||||
for path in (SCRIPTS, TESTS):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
import jyotish_api_server # noqa: E402
|
||||
from test_api_server_security import _handler # noqa: E402
|
||||
|
||||
BEIJING = {
|
||||
"year": 1990,
|
||||
"month": 6,
|
||||
"day": 15,
|
||||
"hour": 12,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": 39.9042,
|
||||
"lon": 116.4074,
|
||||
"tz": 8,
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"transit_date": "2026-10-15",
|
||||
"skip_vedastro_main_entry_overview": True,
|
||||
}
|
||||
|
||||
|
||||
def test_api_chart_keeps_ascendant_nakshatra_from_the_engine(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
|
||||
monkeypatch.setattr(
|
||||
jyotish_api_server,
|
||||
"_attach_vedastro_main_entry_overview",
|
||||
lambda result, _birth: result,
|
||||
)
|
||||
engine = jyotish_api_server._load_local_module("jyotish_engine")
|
||||
result = _handler()._compute_chart_sync(BEIJING)
|
||||
lon = result["ascendant"]["lon"]
|
||||
expected = engine._nakshatra_fields(lon)
|
||||
|
||||
for packet in (result["ascendant"], result["modules"]["chart"]["ascendant"]):
|
||||
assert packet["nakshatra"] == expected["nakshatra"]
|
||||
assert packet["nakshatra_pada"] == expected["nakshatra_pada"]
|
||||
assert packet["nakshatra_lord"] == expected["nakshatra_lord"]
|
||||
assert result["ascendant"]["nakshatra"] == "Uttara Phalguni"
|
||||
assert result["ascendant"]["nakshatra_pada"] == 1
|
||||
assert result["ascendant"]["nakshatra_lord"] == "Sun"
|
||||
@@ -153,7 +153,10 @@ def test_chart_overview_gate_is_off_by_default(monkeypatch) -> None:
|
||||
|
||||
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)
|
||||
# 原值:reset(limit=1) 后第二次立刻 HeavyComputeBusy
|
||||
# 新值:wait_seconds=0
|
||||
# 原因:默认改为短排队;本测试仍锁「名额被占时同步排盘被拒绝」,等待行为不在这里
|
||||
gate.reset_heavy_compute_gate(limit=1, wait_seconds=0)
|
||||
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
|
||||
@@ -172,7 +175,10 @@ def test_chart_overview_gate_only_sync_unskipped_requests(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_busy_log_names_holders_and_not_the_body(caplog) -> None:
|
||||
gate.reset_heavy_compute_gate(limit=1)
|
||||
# 原值:reset(limit=1) 后拒绝日志立刻出现
|
||||
# 新值:wait_seconds=0
|
||||
# 原因:默认会先等 8 秒;本测试仍锁日志只有路径、没有请求正文
|
||||
gate.reset_heavy_compute_gate(limit=1, wait_seconds=0)
|
||||
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"}
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +15,7 @@ from scripts.api_heavy_compute_gate import (
|
||||
HEAVY_COMPUTE_PATHS,
|
||||
HeavyComputeBusy,
|
||||
acquire_heavy_compute_slot,
|
||||
heavy_compute_waiter_count,
|
||||
is_heavy_compute_path,
|
||||
release_heavy_compute_slot,
|
||||
reset_heavy_compute_gate,
|
||||
@@ -93,7 +95,10 @@ def limit_one_gate(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_HEAVY_COMPUTE_CONCURRENCY", "1")
|
||||
monkeypatch.setenv("JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS", "2")
|
||||
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "0")
|
||||
reset_heavy_compute_gate()
|
||||
# 原值:reset() 使用默认「满了立刻拒绝」
|
||||
# 新值:wait_seconds=0,本夹具里的 429 断言仍是立刻拒绝
|
||||
# 原因:产品改为最多等 8 秒;排队成功、等待者满、超时另有测试
|
||||
reset_heavy_compute_gate(wait_seconds=0)
|
||||
yield
|
||||
monkeypatch.delenv("JYOTISH_HEAVY_COMPUTE_CONCURRENCY", raising=False)
|
||||
monkeypatch.delenv("JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS", raising=False)
|
||||
@@ -119,7 +124,10 @@ def test_health_and_light_paths_are_not_gated() -> None:
|
||||
|
||||
|
||||
def test_acquire_fail_fast_then_succeeds_after_release() -> None:
|
||||
reset_heavy_compute_gate(limit=1, retry_after_seconds=3)
|
||||
# 原值:reset(limit=1, retry_after_seconds=3) 后第二路立刻 HeavyComputeBusy
|
||||
# 新值:多传 wait_seconds=0
|
||||
# 原因:默认改为短排队;本测试仍锁 Retry-After=3 与 ERR_COMPUTE_BUSY,不锁「禁止等待」
|
||||
reset_heavy_compute_gate(limit=1, retry_after_seconds=3, wait_seconds=0)
|
||||
first = acquire_heavy_compute_slot("/api/high_rigor_workflow")
|
||||
assert first is not None
|
||||
with pytest.raises(HeavyComputeBusy) as caught:
|
||||
@@ -248,8 +256,87 @@ def test_warm_consultation_does_not_consume_the_user_slot(limit_one_gate, monkey
|
||||
assert "packet" not in warm_box["payload"]
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 2.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_third_request_gets_the_slot_after_one_holder_releases() -> None:
|
||||
reset_heavy_compute_gate(limit=2, retry_after_seconds=2, wait_seconds=2, max_waiters=4)
|
||||
first = acquire_heavy_compute_slot("/api/consultation_workflow")
|
||||
second = acquire_heavy_compute_slot("/api/rectification/v5/score")
|
||||
assert first is not None and second is not None
|
||||
found: dict = {}
|
||||
|
||||
def _third() -> None:
|
||||
found["slot"] = acquire_heavy_compute_slot("/api/consultation_workflow")
|
||||
|
||||
worker = threading.Thread(target=_third)
|
||||
worker.start()
|
||||
assert _wait_until(lambda: heavy_compute_waiter_count() == 1)
|
||||
release_heavy_compute_slot(first)
|
||||
worker.join(timeout=3)
|
||||
assert not worker.is_alive()
|
||||
assert found["slot"] is not None
|
||||
release_heavy_compute_slot(second)
|
||||
release_heavy_compute_slot(found["slot"])
|
||||
reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_waiter_overflow_rejects_immediately() -> None:
|
||||
reset_heavy_compute_gate(limit=1, retry_after_seconds=2, wait_seconds=2, max_waiters=1)
|
||||
held = acquire_heavy_compute_slot("/api/high_rigor_workflow")
|
||||
assert held is not None
|
||||
parked: dict = {}
|
||||
|
||||
def _wait() -> None:
|
||||
try:
|
||||
parked["slot"] = acquire_heavy_compute_slot("/api/consultation_workflow")
|
||||
except HeavyComputeBusy as exc:
|
||||
parked["error"] = exc
|
||||
|
||||
waiter = threading.Thread(target=_wait)
|
||||
waiter.start()
|
||||
assert _wait_until(lambda: heavy_compute_waiter_count() == 1)
|
||||
started = time.monotonic()
|
||||
with pytest.raises(HeavyComputeBusy) as caught:
|
||||
acquire_heavy_compute_slot("/api/rectification/v5/score")
|
||||
assert time.monotonic() - started < 0.3
|
||||
assert caught.value.retry_after_seconds == 2
|
||||
assert caught.value.error_code == "ERR_COMPUTE_BUSY"
|
||||
release_heavy_compute_slot(held)
|
||||
waiter.join(timeout=3)
|
||||
assert not waiter.is_alive()
|
||||
assert parked.get("slot") is not None
|
||||
release_heavy_compute_slot(parked["slot"])
|
||||
reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_queue_timeout_still_returns_busy() -> None:
|
||||
reset_heavy_compute_gate(limit=1, retry_after_seconds=2, wait_seconds=0.2, max_waiters=4)
|
||||
held = acquire_heavy_compute_slot("/api/professional_report_reference")
|
||||
assert held is not None
|
||||
started = time.monotonic()
|
||||
with pytest.raises(HeavyComputeBusy) as caught:
|
||||
acquire_heavy_compute_slot("/api/consultation_workflow")
|
||||
elapsed = time.monotonic() - started
|
||||
assert 0.1 <= elapsed < 2.0
|
||||
assert caught.value.retry_after_seconds == 2
|
||||
assert caught.value.error_code == "ERR_COMPUTE_BUSY"
|
||||
light = acquire_heavy_compute_slot("/api/health")
|
||||
assert light is None
|
||||
release_heavy_compute_slot(held)
|
||||
reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_quality_gate_runs_heavy_compute_gate() -> None:
|
||||
from scripts.run_quality_gate import CORE_PYTEST_TARGETS
|
||||
|
||||
assert "tests/test_api_heavy_compute_gate.py" in CORE_PYTEST_TARGETS
|
||||
assert gate.DEFAULT_CONCURRENCY == 2
|
||||
assert gate.DEFAULT_QUEUE_WAIT_SECONDS == 8
|
||||
assert gate.DEFAULT_MAX_WAITERS == 4
|
||||
|
||||
@@ -139,7 +139,10 @@ def test_numeric_birth_spellings_share_one_chart_cache_key():
|
||||
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
|
||||
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
|
||||
assert skipped not in keys
|
||||
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
|
||||
# 原值:cache_schema_version == 6
|
||||
# 新值:7
|
||||
# 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。
|
||||
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 7
|
||||
|
||||
|
||||
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Phase-1 report_render package: import boundary and moved-helper parity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_PLANET_LABEL_CN = {
|
||||
"Sun": "太阳",
|
||||
"Moon": "月亮",
|
||||
"Mars": "火星",
|
||||
"Mercury": "水星",
|
||||
"Jupiter": "木星",
|
||||
"Venus": "金星",
|
||||
"Saturn": "土星",
|
||||
"Rahu": "北交点",
|
||||
"Ketu": "南交点",
|
||||
}
|
||||
|
||||
|
||||
def test_report_render_imports_without_engine() -> None:
|
||||
code = "\n".join([
|
||||
"import sys",
|
||||
"import scripts.report_render.envelope",
|
||||
"import scripts.report_render.text",
|
||||
"import scripts.report_render.sections_misc",
|
||||
"banned = [name for name in sys.modules if name == 'jyotish_engine' or name.startswith('jyotish_engine.') or name == 'scripts.jyotish_engine' or name.startswith('scripts.jyotish_engine.')]",
|
||||
"assert banned == []",
|
||||
])
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(ROOT)
|
||||
env["PYTHONHASHSEED"] = "0"
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
|
||||
|
||||
def test_planet_label_and_signs_match_engine_literals() -> None:
|
||||
from scripts.jyotish_engine import SIGNS, SIGNS_CN
|
||||
from scripts.report_render import text as moved
|
||||
|
||||
assert list(moved.PLANET_LABEL_CN) == list(ORIGINAL_PLANET_LABEL_CN)
|
||||
for key, value in ORIGINAL_PLANET_LABEL_CN.items():
|
||||
assert moved.PLANET_LABEL_CN[key] == value
|
||||
assert moved.SIGNS == SIGNS
|
||||
assert moved.SIGNS_CN == SIGNS_CN
|
||||
|
||||
|
||||
def _first(packet, kind: str):
|
||||
stack = [packet]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if kind == "bool" and isinstance(current, bool):
|
||||
return current
|
||||
if kind == "str" and isinstance(current, str) and current.strip():
|
||||
return current
|
||||
if kind == "list" and isinstance(current, list) and current:
|
||||
return current
|
||||
if isinstance(current, dict):
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, (list, tuple)):
|
||||
stack.extend(list(current))
|
||||
raise AssertionError(kind)
|
||||
|
||||
|
||||
def test_five_moved_helpers_match_engine_on_real_packet() -> None:
|
||||
from scripts.jyotish_engine import (
|
||||
_bool_text,
|
||||
_envelope_list,
|
||||
_humanize_reader_token,
|
||||
_md_cell,
|
||||
_unwrap,
|
||||
build_professional_report_reference_packet,
|
||||
cmd_full_reading,
|
||||
render_pl9_markdown,
|
||||
)
|
||||
from scripts.professional_report_reference import _export_args
|
||||
from scripts.report_render.envelope import _bool_text as moved_bool
|
||||
from scripts.report_render.envelope import _envelope_list as moved_list
|
||||
from scripts.report_render.envelope import _unwrap as moved_unwrap
|
||||
from scripts.report_render.text import _humanize_reader_token as moved_token
|
||||
from scripts.report_render.text import _md_cell as moved_cell
|
||||
from tests.test_report_reader_main import FICTIONAL
|
||||
|
||||
reading = cmd_full_reading(SimpleNamespace(**FICTIONAL))
|
||||
packet = build_professional_report_reference_packet(reading, _export_args(dict(FICTIONAL)), [])
|
||||
rendered = render_pl9_markdown(packet)
|
||||
assert isinstance(rendered, str) and len(rendered) > 100
|
||||
flag = _first(packet, "bool")
|
||||
label = _first(packet, "str")
|
||||
rows = _first(packet, "list")
|
||||
birth = packet.get("birth_info")
|
||||
assert _bool_text(flag) == moved_bool(flag)
|
||||
assert _md_cell(label) == moved_cell(label)
|
||||
assert _unwrap(birth) == moved_unwrap(birth)
|
||||
assert _humanize_reader_token("Sun") == moved_token("Sun")
|
||||
assert _humanize_reader_token("Aries") == moved_token("Aries")
|
||||
assert _envelope_list(rows) == moved_list(rows)
|
||||
assert _bool_text is moved_bool
|
||||
assert _md_cell is moved_cell
|
||||
assert _unwrap is moved_unwrap
|
||||
assert _humanize_reader_token is moved_token
|
||||
assert _envelope_list is moved_list
|
||||
Reference in New Issue
Block a user