Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21e167a73 | ||
|
|
18fdce6006 | ||
|
|
1de3cc307b | ||
|
|
b184448871 | ||
|
|
2b7112a601 | ||
|
|
10d0c4bba6 | ||
|
|
9308382ef8 | ||
|
|
336286e428 | ||
|
|
9274491681 |
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-15 — 合盘更快(待合入 staging)
|
||||
|
||||
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
|
||||
|
||||
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
|
||||
|
||||
- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。
|
||||
|
||||
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
|
||||
|
||||
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
|
||||
|
||||
@@ -17990,6 +17990,36 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
|
||||
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
|
||||
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
|
||||
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
|
||||
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
|
||||
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
|
||||
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
|
||||
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
|
||||
|
||||
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||
- 来源:issue #64。任务书 T2。
|
||||
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
|
||||
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
|
||||
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
|
||||
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
|
||||
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
||||
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
|
||||
- 相关记录:BUG-721、BUG-1265、BUG-1333。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
|
||||
|
||||
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
||||
|
||||
- 状态:investigating
|
||||
@@ -36042,6 +36072,36 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
|
||||
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
|
||||
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
|
||||
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
|
||||
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
|
||||
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
|
||||
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
|
||||
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
|
||||
|
||||
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
|
||||
- 来源:issue #64。任务书 T2。
|
||||
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
|
||||
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
|
||||
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
|
||||
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
|
||||
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
||||
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
|
||||
- 相关记录:BUG-721、BUG-1265、BUG-1333。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
|
||||
|
||||
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
|
||||
|
||||
- 状态:investigating
|
||||
@@ -36131,3 +36191,20 @@
|
||||
- 相关记录:BUG-203(锁文件,不是这一条)、BUG-1063(人物表列级读取权)、BUG-1348。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/release-path-production-20261014`。编号接在 BUG-1349 之后。
|
||||
|
||||
## BUG-1351 | 自托管下旧版校正评分轮询 rpc 结果多读一步,必抛 TypeError
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-13
|
||||
- 最近更新:2026-10-15
|
||||
- 影响面:旧版生时校正(legacy-guided-v1)的评分轮询,`POST /api/birth-time-journey` 且类型为 `poll_scoring`
|
||||
- 用户现象:这类存量校正在查看评分进度时直接失败,页面拿不到「已领取 / 处理中 / 已完成」。
|
||||
- 触发条件:自托管数据库(当前 staging 与生产都是这条路径)上,对 `claim_birth_time_scoring_job` 的返回值再调用 `.single()`。该函数返回的是表格里的一行,本地适配层把整表收成数组,没有 `.single`。
|
||||
- 根因:数据访问层被标成完整的 Supabase 客户端,编译器看不出适配层没有 `.single`。原有测试用内存仓库,没有走过真实适配层。
|
||||
- 修复:去掉 `.single()`。返回值如果是数组就取第一行,如果已是单行对象就直接用,再做原来的字段校验。Supabase 回滚分支不带 `.single()` 时同样返回数组,行为一致。
|
||||
- 验证:修复前两条单元测试都抛出 `supabase.rpc(...).single is not a function`。修复后这两条通过。另有一条真实迁移测试:函数返回值是数组,认领结果为已领取,且不抛错。`tsc --noEmit` 0 错。
|
||||
- 防复发:适配层 `implements` 自己的 `DataClient`。新增调用如果用了适配层没有的方法,编译直接失败。评分认领有数据库级用例。
|
||||
- 相关记录:BUG-124、BUG-130、BUG-576、BUG-926
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。
|
||||
|
||||
|
||||
@@ -106,7 +106,21 @@ Gitea → Deploy production。Use workflow from:`main`。
|
||||
|
||||
1. 整库还原。没有单独按钮。只允许 `RECOVERY_FORMAT=2` 的恢复点。先看 `/opt/jyotisha-production/.state/recovery-baseline.env`:没有 `RECOVERY_FORMAT=2` 就停,回到第 4 步再点一次创建恢复点。8 月的旧备份不能用。第 4 步的 `recovery_reference` 斜杠后面就是备份文件名,例如 `production-pre-migration-20261014T120000Z.dump.enc`。文件在 `/opt/jyotisha-production/backups/` 下面。先 `sha256sum` 这个文件,和同一份 `recovery-baseline.env` 里的 `RECOVERY_SHA256` 对上,再继续。
|
||||
|
||||
先停 `api` 和 `web`,不要停 `postgres`。口令和密钥只用主机上已有的环境文件,不要贴出来。
|
||||
**先证明备份能解开、能读,再动生产库。** 顺序不能反:先删库再发现备份解不开,网站就停在一个空库上。口令和密钥只用主机上已有的环境文件,不要贴出来,也不要 `echo`:
|
||||
|
||||
```text
|
||||
cd /opt/jyotisha-production
|
||||
set -a; . ./.env.production.database; set +a
|
||||
backup=/opt/jyotisha-production/backups/<备份文件名>
|
||||
sha256sum "$backup" # 必须等于 recovery-baseline.env 里的 RECOVERY_SHA256
|
||||
postgres_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=postgres)
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
|
||||
| sudo -n docker exec -i "$postgres_id" pg_restore --list >/dev/null && echo backup_readable=1
|
||||
```
|
||||
|
||||
上面只读备份、不碰任何库。必须看到 `backup_readable=1`;没看到(解密失败、文件损坏)就停,不要往下做,问产品负责人。
|
||||
|
||||
看到之后,再停 `api` 和 `web`,不要停 `postgres`:
|
||||
|
||||
```text
|
||||
api_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=api)
|
||||
@@ -119,7 +133,7 @@ sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
|
||||
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
|
||||
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "CREATE DATABASE jyotisha;"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in <备份文件> \
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
|
||||
| sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
|
||||
pg_restore -U "$POSTGRES_USER" -d jyotisha --exit-on-error
|
||||
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# PROGRESS · 合盘排盘与报告多余子进程(2026-10-15)
|
||||
|
||||
- 分支:`codex/api-chart-subprocess-gate-20261013`
|
||||
- 实际基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`(任务书写的 `77057a819` 更早)
|
||||
- `scripts/jyotish_api_server.py`:开工 10944 行,做完 10940 行
|
||||
- BUG-1333、BUG-1334。`docs/BUG_HISTORY.md` 后半是同一份历史的副本,两处都补上了。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 合盘两次排盘都带 `skip_vedastro_main_entry_overview: true`,不改 `synastryBirthPayload`。两张盘和婚恋分支的两张九分盘都改成同时发出。任一失败仍走原来的 `catch`。
|
||||
2. 前端 `app/api` 和 `lib` 里发往 `/api/chart` 的请求体必须带这个开关。白名单是空的。
|
||||
3. 父进程先看一次能不能导入 PyJHora。不能就不起年度回放和 Tajika 的子进程,返回和「子进程在没装时」相同的结果。样本在 `tests/fixtures/api-chart-subprocess-gate/`,来自本机真实子进程(当时 `find_spec("jhora")` 为 `None`)。装了之后仍走子进程。
|
||||
4. 解释器查找(含「没找到」)和静态 oracle 校验在进程内各记一次。校验按文件路径、mtime、大小作键。不缓存出生资料。
|
||||
5. 开关 `JYOTISH_GATE_CHART_OVERVIEW` 默认关。只有把它设为 `1` 时,不带 skip、也不是异步排队的 `/api/chart` 才占一个重计算名额。`/api/chart` 没有加进 `HEAVY_COMPUTE_PATHS`。预热咨询不占名额的判断挪进了闸门模块。
|
||||
6. 名额占满时打一行日志:被拒路径、当前占用者路径、已占秒数。不写请求正文。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 闸门、增长护栏、本单新测试 | `.venv\Scripts\python.exe -m pytest tests/test_api_chart_subprocess_gate.py tests/test_api_heavy_compute_gate.py tests/test_api_server_growth_contract.py -q` | 21 passed,退出码 0 |
|
||||
| 报告三份既有测试 | 同上解释器 `-m pytest tests/test_report_reader_main.py tests/test_report_longform_parity.py tests/test_report_density_facts.py -q` | 27 passed,退出码 0 |
|
||||
| 前端本单与相关单文件 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test tests/synastry-chart-skip.test.ts tests/chart-request-skip-contract.test.ts tests/chart-view-route.test.ts tests/ephemeris-route.test.ts` | 22 pass,0 fail |
|
||||
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning |
|
||||
| A/B | `.venv\Scripts\python.exe scripts\research\api_chart_subprocess_ab.py` | 退出码 0。数字见下 |
|
||||
|
||||
`grep` 这三个名字在 `tests/` 里只命中本单新测试:`annual_pyjhora_replay`、`_oracle_progress_snapshot`、`_resolve_external_pyjhora_python`。
|
||||
|
||||
## A/B 数字
|
||||
|
||||
合盘:串行不带 skip 中位 8.0596 秒、2 次 `subprocess.run`;并发带 skip 中位 0.0166 秒、0 次。盘面摘要逐字节一致。耗时约为对照的 0.2%。
|
||||
|
||||
报告:强制仍起子进程中位 16.5322 秒、7 次;短路后 15.3235 秒、1 次。去掉易变字段后逐字节一致。少掉的 6 次是年度回放和 Tajika。
|
||||
|
||||
名额模拟(种子 64,一分钟,未改规则):
|
||||
|
||||
| 方案 | 咨询被拒 | 报告被拒 | 校正被拒 | 最高并发 | 碰到不得加池的那条 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| O1 现状 | 40% | 0% | 17% | 2 | 否 |
|
||||
| O2 咨询单独小池 | 0% | 0% | 17% | 3 | 是 |
|
||||
| O3 咨询最多再等 1.5 秒 | 30% | 0% | 33% | 2 | 否 |
|
||||
| O4 重任务最多占 1 个 | 0% | 0% | 67% | 2 | 否 |
|
||||
|
||||
全文在 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
|
||||
|
||||
## 改既有断言
|
||||
|
||||
无。原值 / 新值 / 原因:没有改旧断言。
|
||||
|
||||
## 偏离
|
||||
|
||||
- `/api/health` 没有加 `heavy_compute` 字段。状态函数 `heavy_compute_status()` 在闸门模块里,接到健康检查会让主文件变长。任务书允许砍掉这一项。
|
||||
- 星盘名额开关默认关。打开会让不带 skip 的外部调用可能拿到 429,任务书要求先有 A/B 再提议,本单不打开。
|
||||
- O2、O3、O4 没有实现。
|
||||
- 报告对照不是 staging 源码另导入,而是本分支把「已安装」强制打开。静态校验缓存两边都在,所以耗时差只反映那 6 个子进程。
|
||||
- 合盘子进程是 2 → 0,不是任务书举例的 4 → 0。这台机器两张盘合计只数到 2 次 `subprocess.run`。
|
||||
- 没有「装了 PyJHora」的计时。父进程强制当成已安装时,子进程仍会因为没装而失败,这一组用来代表「不短路就要付启动开销」。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 本机 `.venv`:`sys.executable` 是工作树的 `python.exe`;`shutil.which("python3")` 是 Windows 应用商店占位;`python3.14` 没有;`find_spec("jhora")` 是 `None`。
|
||||
- Docker 在,没有本地 `jyotisha-api` 镜像,镜像里的解释器路径没有核实。
|
||||
- 生产装了 VedAstro、并且开了外网时,不带 skip 的排盘真实耗时本机测不到。
|
||||
- 名额被占满后连带拒绝的真实频率,要等日志上了 staging 再看。
|
||||
|
||||
## 需拍板
|
||||
|
||||
轻重任务要不要分开限流,本单不改。模拟里,让咨询少被拒,校正就会多被拒;给咨询单独开池会让同时在跑的个数超过 2。要改规则需要另立一单。
|
||||
|
||||
## 默认值
|
||||
|
||||
- `JYOTISH_GATE_CHART_OVERVIEW` 默认关。
|
||||
- 合盘 skip 和报告短路没有开关。
|
||||
|
||||
## 2026-10-15 打回修复
|
||||
|
||||
合盘路由的合同跟上了新写法:仍锁本人和对方各请求一次排盘,并锁住关闭外部概览。对照脚本默认写到系统临时目录,只有带 `--out` 才写指定文件,不再覆盖已入库的记录。业务代码没改。名额配额仍不实现。没有新 bug 编号,补在 BUG-1333 的两处副本上。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 用户数据合同 | `.venv\Scripts\python.exe -m pytest tests/test_supabase_user_data_contract.py -q` | 8 passed,退出码 0 |
|
||||
| 脚本默认路径 | 调用 `resolve_record_path` | 默认不在仓库内,也不是已入库的 json;`--out` 指向传入路径 |
|
||||
| 源码文本检索 | `tests` 与 `frontend/tests` 里的合盘路由、`postPython("/api/chart"`、`postPython("/api/varga_full"` | 只有这一条合同在断言旧写法,已改。其它命中是在读路由文件,没有断言旧的两次调用。分盘前缀仍匹配,没改 |
|
||||
| 类型检查 | 在 `frontend` 下 `node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| lint | 在 `frontend` 下 `npm run lint` | 退出码 0,0 error,123 条原有 warning |
|
||||
|
||||
原值:`postPython("/api/chart", selfPayload)` 与 `postPython("/api/chart", partnerPayload)`。新值:两次调用都展开对应 payload,并带 `skip_vedastro_main_entry_overview: true`。原因:合盘改为同时排两张盘并关掉用不上的外部概览,合同要锁住这两次请求和这个开关,不放宽。
|
||||
|
||||
偏离:先合并了 `origin/staging` `1de3cc307be9134a3b8d1d606c738c1468414118`。相对打回 SHA `336286e4`,`frontend/src` 里多出来的是数据访问层那一单,不是这次修复改的。技能目录下的同名脚本没改,它的输出根不是仓库的 `docs/testing`。
|
||||
@@ -0,0 +1,92 @@
|
||||
# PROGRESS · 数据访问层 DataClient 类型(第一阶段)
|
||||
|
||||
- 分支:`codex/data-layer-types-20261013`
|
||||
- 基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`
|
||||
- 前置:`origin/codex/node-runtime-resources-20261013` 已是 `origin/staging` 的祖先(`merge-base --is-ancestor` 退出码 0)。`local-postgres-client-core.ts` 只加了 `import type` 和两处 `implements`。
|
||||
- `frontend/src/lib/rectification-agentic/session.ts` 已不在,本单没有改它。
|
||||
- 编号:任务书预留 BUG-1343。开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1350,1343 没有条目。按台账规则从最大号往后,记为 **BUG-1351**。
|
||||
|
||||
## T1 · 编译器暴露的 2 条
|
||||
|
||||
`DataClient` 换上之后、缺陷尚未修时,`tsc --noEmit` 退出码 2,恰好 2 条:
|
||||
|
||||
```
|
||||
src/app/api/chart-profiles/route.ts(59,40): error TS7006: Parameter 'row' implicitly has an 'any' type.
|
||||
src/lib/birth-time-scoring-job-store.ts(103,10): error TS2339: Property 'single' does not exist on type 'Promise<Readonly<{ data: any; error: Readonly<{ message: string; code?: string | undefined; }> | null; count?: number | null | undefined; }>>'.
|
||||
```
|
||||
|
||||
`grep`:`as unknown as SupabaseClient` 在 `frontend/src` 无结果。`@supabase/supabase-js` 只剩 `admin-client-core.ts` 的 `createClient` 回滚分支。
|
||||
|
||||
## T2 · 缺陷修复红转绿
|
||||
|
||||
修复前,`claimScoringJob` 对返回数组的假 `rpc` 调用 `.single()`:
|
||||
|
||||
- `claimScoringJob reads the first row when rpc returns an array`:失败,`TypeError: supabase.rpc(...).single is not a function`
|
||||
- `claimScoringJob also accepts the single-object rpc shape`:同样失败
|
||||
|
||||
去掉 `.single()` 并兼容数组与单对象之后,这两条通过。真实迁移用例 `claim_birth_time_scoring_job returns an array and claimScoringJob accepts it` 通过:`data` 是数组,第一行 `claim_state` 为 `claimed`,store 返回 `{ kind: "claimed", algorithmVersion }`。第一次启动测试库时迁移进程只留下笼统的 `database migration failed`(脚本会把非预期异常收成这一句),容器未到 Healthy。同一条用例在容器 Healthy 后重跑通过。
|
||||
|
||||
`chart-profiles/route.ts` 的 `row` 标为 `unknown`。
|
||||
|
||||
修完后 `tsc --noEmit` 退出码 0。`npm run lint` 退出码 0,0 error,123 warning(与改动前的存量警告数量一致,本单没有新增 warning)。
|
||||
|
||||
## T3 · 转译差分
|
||||
|
||||
类型层改动用 esbuild 0.28.1、`--format=esm`、`--tsconfig=frontend/tsconfig.json`,旧文件与新文件都按这份 tsconfig 转译。除 T2 的两个文件和新建的 `data-client.ts` 外,全部 `SAME`:
|
||||
|
||||
- `src/lib/birth-time-journey-case-loader.ts`
|
||||
- `src/lib/birth-time-journey-store.ts`
|
||||
- `src/lib/birth-time-journey-turn-persistence.ts`
|
||||
- `src/lib/db/local-postgres-client-core.ts`
|
||||
- `src/lib/rectification-agentic/v9/case-service.ts`
|
||||
- `src/lib/rectification-agentic/v9/product-domain.ts`
|
||||
- `src/lib/rectification-agentic/v9/tool-service.ts`(只改了一句注释;转译后注释被去掉,产物相同)
|
||||
- `src/lib/rectification-in-chat-server.ts`
|
||||
- `src/lib/supabase/admin-client-core.ts`
|
||||
- `src/lib/supabase/server.ts`
|
||||
- `src/mastra/rectification-v9-tools.ts`
|
||||
|
||||
任务书把旧文件写到系统临时目录再转译。esbuild 0.28.1 会沿文件所在目录寻找 `tsconfig.json`。临时目录找不到本仓库的 `tsconfig.json`(`target` 为 ES2017),类字段的转译结果会和仓库内文件不一致,即使源码只有类型或注释差别也会报 DIFF。两边都带上同一份 `--tsconfig` 后为 SAME。这不是运行时变化。
|
||||
|
||||
## T4 · 把 data 收成 unknown 的错误清单
|
||||
|
||||
临时把 `UntypedData` 改成 `unknown` 后 `tsc --noEmit` 退出码 2,共 93 条、12 个文件。随后已改回 `any`。
|
||||
|
||||
| 条数 | 文件 |
|
||||
| ---: | --- |
|
||||
| 24 | `src/app/api/account/route.ts` |
|
||||
| 17 | `src/app/api/consult/route.ts`(其中 1 条是 `DataClient` 不能赋给 `GuardedTitleClient`,`consult/route.ts` 约 1055 行) |
|
||||
| 16 | `src/app/api/consult/status/route.ts` |
|
||||
| 12 | `src/app/api/payment/epay/status/route.ts` |
|
||||
| 7 | `src/lib/epay/availability.ts` |
|
||||
| 5 | `src/lib/rectification-agentic/v9/product-domain.ts` |
|
||||
| 4 | `src/app/api/sessions/route.ts` |
|
||||
| 3 | `src/lib/rectification-agentic/v9/agent-route-request.ts` |
|
||||
| 2 | `src/app/api/chart-profiles/[id]/route.ts` |
|
||||
| 1 | `src/app/api/account/avatar/route.ts` |
|
||||
| 1 | `src/app/api/chart-profiles/route.ts` |
|
||||
| 1 | `src/app/api/sessions/[id]/route.ts` |
|
||||
|
||||
和任务书写作时的 122 条相比,`consult/route.ts` 少了 10 条,`chart-profiles/route.ts` 少了 1 条(本单已把 `row` 标成 `unknown`)。没有单独再出现一条 `session-title-guard.ts` 的错误,`GuardedTitleClient` 那条记在 `consult/route.ts` 里。这些留给第二阶段,本单没有改那些业务文件。
|
||||
|
||||
## test:db
|
||||
|
||||
`npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`)约 19 分钟,退出码 1。汇总:85 条,通过 72,失败 13,取消 0,跳过 0。
|
||||
|
||||
失败的 13 条与 `tools/agent-loop/windows-known-failures.txt` 逐条一致,没有多出来的失败:
|
||||
|
||||
- staging backups are encrypted, atomic, private, and retain the newest three
|
||||
- rejects destructive backup directory aliases and symlink components before mutation
|
||||
- rejects unsafe writable backup parents before creating the target
|
||||
- rejects a direct canonical sticky shared backup directory before chmod
|
||||
- creates every absent backup path component privately despite a permissive caller umask
|
||||
- same-second backups publish once without overwriting the completed archive
|
||||
- find enumeration failures preserve existing backups and do not report completion
|
||||
- refuses full disks and removes a failed-pipeline partial file
|
||||
- database env validator accepts punctuated literal required secrets
|
||||
- database env validator enforces an explicit staging owner uid without printing values
|
||||
- database env validator accepts a private valid file without printing values
|
||||
- personal report job migration is atomic, lease-bound, recoverable, and owner read-only
|
||||
- personal report sections enforce owner-read RLS and service-owned durable transitions
|
||||
|
||||
本单新增的 3 条(两条假客户端、一条真实迁移认领)都在通过的 72 条里。
|
||||
@@ -213,6 +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-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) |
|
||||
@@ -482,12 +483,14 @@
|
||||
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
|
||||
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
|
||||
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
|
||||
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
|
||||
| `TASK-api-chart-subprocess-gate-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-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` |
|
||||
| `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表、Ashtottari 出生余额(BUG-1341、1344) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` |
|
||||
| `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` |
|
||||
| `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` |
|
||||
| `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` |
|
||||
| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# TASK · 答题推理强度开关修复单(issue #65,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-15
|
||||
- 原任务书:`docs/tasks/TASK-answer-effort-ab-20261013.md`
|
||||
- 被验 SHA:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`(`codex/answer-effort-ab-20261013`)
|
||||
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
|
||||
|
||||
## 1. 验收结论:开关逻辑通过,前端全量多出 1 条失败
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 开关默认关时请求体不变 | 通过 | `CONSULT_ANSWER_REASONING_EFFORT` 未设或为空时 `answerEffortOptions` 返回 `{}`;非法值只警告一次按未设处理 |
|
||||
| 只在答题步加强度 | 通过 | 主循环第 0 步 `answerReady=false`;公开日历从第一步即答题 |
|
||||
| 改既有断言 | 通过 | `consult-answer-clock-20261001.test.ts` 三栏齐全 |
|
||||
| tsc / lint / Python 全量 | 通过 | Python 与 staging 逐条一致(一条 prashna 族测试并发下抖动,单跑通过) |
|
||||
| **前端全量 `npm test`** | **未通过:新增 1 条** | `frontend/tests/runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」 |
|
||||
|
||||
**根因**:`frontend/scripts/research/answer-effort-probe.mts` 约 186 行 `new URL("../../tests/fixtures/answer-effort-switch-20261013.json", import.meta.url)`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,`next build` 会类型检查它们——推上 staging 后镜像构建会失败(BUG-1140;#58、#62 已因同一形状各打回一次)。
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 把 `answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`(文件名不得以 `.test.ts` / `.test.tsx` 结尾),路径改相对;同步改 PROGRESS 与 `docs/testing/answer-effort-ab-20261013.md` 里的命令。
|
||||
2. **交回前必须本机跑一次** `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`,fail 与 cancelled 为 0,贴进回报。
|
||||
3. 业务代码不改;开关默认值不改。
|
||||
|
||||
## 3. 非阻断提醒(不要求本轮改,写进 PROGRESS 即可)
|
||||
|
||||
开关打开时,`prepareStep` 返回的 `providerOptions` 来自 `agentGenerationSettings(model, { thinking: "enabled", ... })`。确认它不会把基础流选项里原有的 `providerOptions`(思考预算、缓存等)整体覆盖掉;若会覆盖,在 BLOCKED.md 记下,等有模型 key 的 A/B 一并核对。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
前端全量 `npm test` 失败清单与 staging 基线逐名相同;`git diff ab7f66be..<新 SHA> -- frontend/src` 为空。
|
||||
|
||||
## 5. 让步顺序
|
||||
|
||||
无。第 1 次打回。
|
||||
@@ -0,0 +1,37 @@
|
||||
# TASK · 合盘与报告子进程修复单(issue #64,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-15
|
||||
- 原任务书:`docs/tasks/TASK-api-chart-subprocess-gate-20261013.md`
|
||||
- 被验 SHA:`336286e428c7cb43cac71eae5286e112c0b500b7`(`codex/api-chart-subprocess-gate-20261013`)
|
||||
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
|
||||
- BUG 编号:沿用本单编号
|
||||
|
||||
## 1. 验收结论:内容通过,Python 全量多出 1 条失败
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 合盘带 skip、两两并发 | 通过 | Claude 在 Linux 复跑 `scripts/research/api_chart_subprocess_ab.py`:串行 1.19 s / 4 个子进程 → 并发带 skip 0.015 s / 0 个,摘要逐字节一致 |
|
||||
| 报告正文 | 通过 | 同脚本 `report_bytes_match: true`(本机装了 PyJHora,短路不触发,子进程数不变;生产不装,以 Grok 回报为准) |
|
||||
| 进程内缓存 | 通过 | 只缓存「PyJHora 是否可导入」「外部解释器路径」「静态 oracle 文件校验(键含路径 + mtime + 大小)」,不含出生资料(BUG-721) |
|
||||
| 主文件 | 通过 | 类方法数不变,行数减少 |
|
||||
| 前端全量 / tsc / lint | 通过 | 失败清单与 staging 基线相同 |
|
||||
| **Python 全量(与 staging 逐条比对)** | **未通过:新增 1 条** | `tests/test_supabase_user_data_contract.py::test_synastry_route_orchestrates_python_chart_and_ashtakoot`:`assert 'postPython("/api/chart", selfPayload)' in route` |
|
||||
|
||||
**根因**:这条 Python 合同按源码文本检查 `frontend/src/app/api/synastry/route.ts` 里的 `postPython("/api/chart", selfPayload)`。本单把它改成了 `Promise.all` 里的 `postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })`,合同没跟着改(AGENTS.md §7.8;Python 测试也会读前端源码,所以前端改动要 grep `tests/` 和 `frontend/tests/` 两处)。
|
||||
|
||||
另:本机复跑时 `api_chart_subprocess_ab.py` 会**覆盖仓库里的** `docs/testing/api-chart-subprocess-gate-ab-20261013.json`。研究脚本默认不应写已入库的记录文件:改为默认写到临时目录或 `artifacts/`,只有显式 `--out <路径>` 时才写指定文件。
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 改 `tests/test_supabase_user_data_contract.py` 那条断言,按 §7.3 写「原值 / 新值 / 原因」三栏,**意图不放宽**:仍要锁住「合盘路由向 Python `/api/chart` 请求本人和对方两张盘」,新断言要同时锁住带 `skip_vedastro_main_entry_overview: true`;该测试里其它 token(如 ashtakoot 调用)不动。
|
||||
2. `grep -rn "synastry/route\|postPython(\"/api/chart\"\|postPython(\"/api/varga_full\"" tests frontend/tests`,其它按源码文本断言的同样处理。
|
||||
3. `api_chart_subprocess_ab.py` 默认不覆盖入库文件(见上)。
|
||||
4. 业务代码不改。PROGRESS 末尾记一行。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量无新增;`git diff 336286e4..<新 SHA> -- frontend/src scripts/*.py` 只有第 3 条的脚本改动。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。第 1 次打回。
|
||||
@@ -0,0 +1,102 @@
|
||||
# TASK · 星盘页上升行无星宿、表头错位、大运卡左边线(issue #53 第二轮)
|
||||
|
||||
- 日期:2026-10-15
|
||||
- 来源:issue #53,产品负责人 10-15 在 staging 星盘页反馈:D1 本命与月亮盘的上升行「星宿 / 宿主 / pada / 状态」全是「—」;分盘表「庙旺」表头与内容不对齐;大运卡左侧那条线要去掉
|
||||
- 执行方:**Grok(Windows + Docker)**,分支 `codex/chart-ascendant-row-20261015`,只推这个分支
|
||||
- 验收:Claude(Linux)
|
||||
- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时为 BUG-1350);**BUG-1351 已被 issue #65 的分支占用,本单从 BUG-1352 起**
|
||||
|
||||
## 1. 基线
|
||||
|
||||
`origin/staging` `10d0c4bba`。
|
||||
|
||||
## 2. 事故实证与根因
|
||||
|
||||
### A · 上升行星宿 / 宿主 / pada 为空(D1 与月亮盘同因)——BUG-1201 修复从未打通
|
||||
|
||||
- 引擎正确:`scripts/jyotish_engine.py::compute_chart_data`(约 12378 行)给上升加了 `**_nakshatra_fields(asc_deg)`;`scripts/domain_calculation_service.py::compute_chart`(约 242 行)原样传出。
|
||||
- **`/api/chart` 把它们丢了**:`scripts/jyotish_api_server.py::_compute_chart_sync` 约 6660–6666 行重建 `result['ascendant']`,只留 `sign / sign_idx / degree / degree_in_sign / lon`;`result['modules']['chart']['ascendant']` 用的是同一个字典。该段来自 06-24 的 `494e3870`,BUG-1201 的 `ae18952e` 没碰它。虚构盘(1990-06-15 12:00 北京)实测 `_compute_chart_sync` 返回的上升没有任何星宿键。
|
||||
- 前端映射没问题:`frontend/src/lib/chart-view-mapper.ts::ascendantRow`(约 390–409 行)读 `nakshatra / nakshatra_pada / nakshatra_lord`,缺失时按设计回落「—」。月亮盘复用同一个 `view.vedic.ascendant`(`chart-vedic-tab.tsx` 约 351–355 行)。
|
||||
- **不是缓存问题**:服务器缓存 900 s、前端 5 分钟,`cache_schema_version` 已是 6;BUG-1201 那次把 4→5 也没用,因为接口输出根本没变。
|
||||
- **为什么没拦住**:`tests/test_report_chart_blank_columns.py` 只调 `compute_chart_data`;前端 golden `frontend/tests/fixtures/chart-view-golden.json` 取自 `domain_calculation_service.compute_chart` 而不是接口真实响应,又手工补了 `_nakshatra_fields`——违反 AGENTS.md §7.4「合同 fixture 必须来自真实引擎响应」,所以丢字段的那一层从没被测到。
|
||||
|
||||
### B · 上升行「状态」写「—」
|
||||
|
||||
`chart-vedic-tab.tsx::AscendantRow` 约 99 行写死 `<td>—</td>`,注释与 `frontend/DESIGN.md` 约 1181 行写「上升没有尊贵,状态格写「—」」——这是当时的有意设计,不是回归。产品负责人这次把它算作「没数据」。
|
||||
|
||||
### C · 表头与内容不对齐
|
||||
|
||||
`frontend/src/app/globals.css` 约 5619–5623 行把 `.chart-page-planet-table` 的所有 `th / td` 设为左对齐;约 5658 行只把 `td.chart-page-planet-mid` 居中;对应的 `<th scope="col">` 没有这个类,于是表头靠左、内容居中。所有 `.chart-page-planet-table` 都有:D1 / 月亮盘的「速度、宿主、pada」,分盘的「庙旺、同位」,行运的「速度」,Bhava 的「换宫」。
|
||||
|
||||
### D · 大运卡左边线
|
||||
|
||||
`globals.css` 约 5774–5778 行 `.chart-page-dasha-period.is-current { …; box-shadow: inset 2px 0 0 var(--color-focus); }`(`--color-focus` 是陶土色),挂在当前大运上(默认展开的那一张,`chart-dasha-tab.tsx` 约 115 行)。来自 `1e3570b4`;`frontend/DESIGN.md`「大运 Tab」一节没写,也没有测试引用。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
- 产品负责人在 issue #53 授权修复上面四项。
|
||||
- **B(按推荐先定,产品不同意回一句即改)**:上升行「状态」改写「不适用」,与分盘表上升「庙旺」格(BUG-1099)、报告上升行(BUG-1314)同一口径。**这推翻 `frontend/DESIGN.md` 约 1181 行「状态格写「—」」**,DESIGN.md 同步改。
|
||||
- **D(按推荐先定)**:只去掉左边那条线,当前大运的浅底与细边框保留,用来标「现在走的是这一段」。
|
||||
- 不推翻 BUG-1201 的设计(上升行要有星宿 / 宿主 / pada),这次是把它真正打通;记为新 BUG「复发自 BUG-1201(修复未打通接口层)」,并在 BUG-1201 记录里补一行更正其验证结论。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. `scripts/jyotish_api_server.py`:类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长(`tests/test_api_server_growth_contract.py`;注释里写这个词也算);只在 `_compute_chart_sync` 里补字段。
|
||||
2. 不改引擎数值;不改 `_nakshatra_fields`、`compute_chart_data`。
|
||||
3. 合同 fixture 必须来自**接口真实响应**(§7.4);不得手工补字段。
|
||||
4. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep(§7.8,Python 测试也会读前端源码):
|
||||
`grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden\|ascendant\b" frontend/tests tests | grep -v node_modules`
|
||||
已知:`frontend/tests/chart-varga-table.test.tsx` 约 101–103 行断言上升行末尾 `<td>—</td>`;`frontend/tests/fixtures/chart-d1-table-baseline.html`;`chart-page-view.test.tsx`、`chart-page-hook.test.ts`。
|
||||
5. `frontend/scripts/**`、`frontend/src/**` 不得从 `frontend/tests/**` 导入或读文件(BUG-1140,近期已 5 次打回)。交回前必须本机跑 `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`。
|
||||
6. 不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/**`;不动数据库;不升级依赖。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T1 · 接口带出上升的星宿字段(新 BUG,复发自 BUG-1201)
|
||||
|
||||
- `_compute_chart_sync` 重建 `result['ascendant']` 时,从 `ascendant_data` 带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`(引擎已有就原样带;没有就调 `_nakshatra_fields(lon)`);`cache_schema_version` 6 → 7。
|
||||
- 验收:
|
||||
- 新回归测试**经过 `/api/chart` 处理函数**(用现有测试里已有的调用方式,不得新增 `JyotishAPIHandler.__new__`):虚构盘的响应 `ascendant` 有三个字段,且等于 `_nakshatra_fields(lon)`;`modules.chart.ascendant` 同样有。
|
||||
- 用这次的**接口真实响应**重新生成 `chart-view-golden.json` 的上升部分,`fixtureProvenance` 写明命令;删掉手工补字段的那一步。
|
||||
- 前端:D1 与月亮盘上升行渲染出星宿、宿主、pada(测试断言具体值,不是只断言「不是 —」)。
|
||||
|
||||
### T2 · 上升行状态「不适用」
|
||||
|
||||
- `AscendantRow` 状态格改「不适用」;DESIGN.md 约 1181 行同步改;`chart-varga-table.test.tsx` 与 baseline html 按三栏改。
|
||||
|
||||
### T3 · 表头对齐
|
||||
|
||||
- 给居中列对应的 `<th scope="col">` 加 `chart-page-planet-mid`(或用列选择器),让表头与内容同为居中;涉及 D1 / 月亮盘、分盘、行运、Bhava 四类表。8 列表的 markup 若被 DESIGN.md 约 1195 行写成「冻结」,改用 CSS 选择器、不改 markup,并在 DESIGN.md 记一句。
|
||||
- 验收:渲染测试断言每张表里居中列的 `th` 与 `td` 同为居中(类名或计算后的选择器);DESIGN.md 更新。
|
||||
|
||||
### T4 · 去掉大运卡左边线
|
||||
|
||||
- 删 `.chart-page-dasha-period.is-current` 的 `box-shadow` 那一行,浅底与边框保留;DESIGN.md「大运 Tab」一节加一句「当前大运用浅底和细边框标出,不画左侧强调线」。
|
||||
- 验收:一条 CSS 合同测试断言 `.chart-page-dasha-period.is-current` 不含 `box-shadow`。
|
||||
|
||||
### T5 · 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md`:新 BUG(上升星宿字段被 `/api/chart` 丢弃,复发自 BUG-1201,写清为何 golden 没拦住);BUG-1201 记录补一行更正;表头错位、左边线各一条或合为一条 UI 记录。
|
||||
- `docs/tasks/PROGRESS-chart-ascendant-row-20261015.md`、`CHANGELOG.md`、`frontend/DESIGN.md`。
|
||||
- 真机清单 `docs/testing/chart-ascendant-row-20261015.md`。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
T1 → T3 → T2 → T4。T1 不可让。
|
||||
|
||||
## 7. 验收命令分工
|
||||
|
||||
| 谁 | 命令 |
|
||||
| --- | --- |
|
||||
| Grok 自跑 | `python -m pytest tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py <新测试> -q`;前端 `tsc --noEmit`、`npm run lint`、改到 / 新增的前端测试单文件,**加** `tests/runtime-sources-no-test-imports.test.ts`(fail 与 cancelled 为 0)。前端全量与 Python 快速门不跑 |
|
||||
| Claude 验收 | 上述 + Python 全量与基线逐条比对 + 快速门 + 前端全量 `npm test` + `next build`(`/` 仍 Static,首屏 gzip ±2%) |
|
||||
| 不需要 | `test:db` |
|
||||
|
||||
## 8. 开工前置
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/chart-ascendant-row-20261015 .worktrees/chart-ascendant-row-20261015 origin/staging
|
||||
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # BUG-1351 已被 #65 占用
|
||||
grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden" frontend/tests tests
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"charts": {
|
||||
"serial_seconds_median": 8.0596,
|
||||
"concurrent_skip_seconds_median": 0.0166,
|
||||
"serial_subprocesses_median": 2,
|
||||
"concurrent_skip_subprocesses_median": 0,
|
||||
"summary_bytes_match": true
|
||||
},
|
||||
"report_forced_subprocess": {
|
||||
"seconds_median": 16.5322,
|
||||
"subprocesses_median": 7
|
||||
},
|
||||
"report_short_circuit": {
|
||||
"seconds_median": 15.3235,
|
||||
"subprocesses_median": 1
|
||||
},
|
||||
"report_bytes_match": true,
|
||||
"schemes": [
|
||||
{
|
||||
"scheme": "O1",
|
||||
"consult_429": "0.40",
|
||||
"report_429": "0.00",
|
||||
"score_429": "0.17",
|
||||
"peak": 2,
|
||||
"touches_bug_717": false
|
||||
},
|
||||
{
|
||||
"scheme": "O2",
|
||||
"consult_429": "0.00",
|
||||
"report_429": "0.00",
|
||||
"score_429": "0.17",
|
||||
"peak": 3,
|
||||
"touches_bug_717": true
|
||||
},
|
||||
{
|
||||
"scheme": "O3",
|
||||
"consult_429": "0.30",
|
||||
"report_429": "0.00",
|
||||
"score_429": "0.33",
|
||||
"peak": 2,
|
||||
"touches_bug_717": false
|
||||
},
|
||||
{
|
||||
"scheme": "O4",
|
||||
"consult_429": "0.00",
|
||||
"report_429": "0.00",
|
||||
"score_429": "0.67",
|
||||
"peak": 2,
|
||||
"touches_bug_717": false
|
||||
}
|
||||
],
|
||||
"jhora_installed": false
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 合盘排盘与报告子进程 A/B(2026-10-15)
|
||||
|
||||
Windows 本机,工作树 `.venv` 的 `python.exe`。`importlib.util.find_spec("jhora")` 是 `None`。`python3` 指向 Windows 应用商店占位程序,`python3.14` 不存在。Docker 守护进程在,本机没有 `jyotisha-api` 镜像,所以没有进镜像再探一次。本机没有 VedAstro SDK,外网关闭(`VEDASTRO_ENABLE_NETWORK=0`)。没有「真 SDK」一组。
|
||||
|
||||
脚本:`scripts/research/api_chart_subprocess_ab.py`。每组 3 次,取中位数。盘是任务书里的虚构盘(1990-01-01 12:00 与 1992-01-02 12:00,新德里)。星盘缓存 TTL 设为 0。易变字段在比对前丢掉:`generated_at`、`created_at`、`timestamp`、`elapsed_seconds`、`elapsed_ms`、`duration_ms`、`request_id`。原始数字在同目录 `api-chart-subprocess-gate-ab-20261013.json`。
|
||||
|
||||
对照不是另一次 `git show` 出 staging 再导入。星盘对照是本分支的 `_compute_chart_sync`:不带 skip、两张盘串行。报告对照是本分支把「参照引擎已安装」强制打开,从而仍会起子进程;静态校验缓存和解释器查找缓存两边都开着。所以报告耗时差只含那 6 个注定失败的回放进程,不含静态校验少跑的那一次。静态校验「连调两次只跑一次」由单测覆盖。
|
||||
|
||||
## 合盘两张盘
|
||||
|
||||
| | 中位耗时 | 中位 `subprocess.run` 次数 | 盘面摘要 |
|
||||
| --- | --- | --- | --- |
|
||||
| 对照:不带 skip,串行 | 8.0596 秒 | 2 | — |
|
||||
| 本单:带 skip,并发 | 0.0166 秒 | 0 | 与对照逐字节一致 |
|
||||
|
||||
0.0166 / 8.0596 ≈ 0.2%,低于「不超过对照 10%」的线。子进程从 2 降到 0。任务书写的「4 → 0」是按每张盘 2 个子进程、两张合计 4 个来估的。这台机器上两张盘合计只数到 2 次 `subprocess.run`。不带 skip 的 8 秒里,大部分是没装 SDK 时子进程空等,不是盘面计算本身。
|
||||
|
||||
## 个人长报告(完整数据版,中英,带事实表)
|
||||
|
||||
| | 中位耗时 | 中位 `subprocess.run` 次数 | 中英正文与事实表 |
|
||||
| --- | --- | --- | --- |
|
||||
| 对照:强制仍起回放子进程 | 16.5322 秒 | 7 | — |
|
||||
| 本单:没装参照引擎就不起 | 15.3235 秒 | 1 | 去掉易变字段后逐字节一致 |
|
||||
|
||||
少 6 次,对应年度回放 3 次和 Tajika 3 次。剩下的 1 次两边都有,短路没有把它去掉。子进程数 1,低于「模拟生产不超过 3」的线。耗时 15.32 秒,不比对照的 16.53 秒差。本机没装 PyJHora,没有再跑「装了参照引擎」的一组;那种环境仍必须走子进程,单测用桩覆盖。
|
||||
|
||||
生产上的收益仍按任务书:大约每份报告 0.5 到 0.9 秒,不是装了参照引擎的机器上看到的约 5 秒。
|
||||
|
||||
## 名额方案模拟(没改规则)
|
||||
|
||||
固定一分钟:2 份报告(每次 13 秒)、20 次咨询(每次 0.2 秒)、6 次校正打分(每次 8 秒)。随机种子 64。并发上限按现状 2。O2、O3、O4 只在脚本里模拟。
|
||||
|
||||
| 方案 | 咨询被拒 | 报告被拒 | 校正被拒 | 同时在跑的最高个数 | 是否碰到「不得加池、不得提高并发」 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| O1 现状 | 40% | 0% | 17% | 2 | 否 |
|
||||
| O2 咨询单独小池 | 0% | 0% | 17% | 3 | 是,本单不实现 |
|
||||
| O3 咨询最多再等 1.5 秒 | 30% | 0% | 33% | 2 | 否,本单不实现 |
|
||||
| O4 重任务最多占 1 个名额 | 0% | 0% | 67% | 2 | 否,本单不实现 |
|
||||
|
||||
这是一分钟的草图,不是 staging 上的真实频率。咨询少被拒的时候,校正会多被拒(O3、O4)。O2 咨询不再被拒,但同时在跑的个数到了 3,越过了现在的上限。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 真机清单 · 星盘页上升行、表头、大运卡(issue #53 第二轮)
|
||||
|
||||
部署到 staging 后,打开本人星盘页(手机和电脑各一次):
|
||||
|
||||
1. D1 本命表第一行「上升」:星宿、宿主、pada 都有值;状态写「不适用」。
|
||||
2. 切到月亮盘:上升那一行同样有星宿、宿主、pada,状态「不适用」。
|
||||
3. 切到任一分盘(如 D9):「庙旺」「同位」表头和下面的内容都居中对齐。
|
||||
4. D1 表的「速度、宿主、pada」,行运表的「速度」,Bhava 表的「换宫」:表头和内容对齐。
|
||||
5. 大运 Tab:当前那段大运没有左侧那条彩色竖线,仍有浅色底能看出是「现在这一段」。
|
||||
@@ -56,7 +56,7 @@ export async function GET() {
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
const profiles = (data ?? []).map((row) => toClientProfile(row as Record<string, unknown>));
|
||||
const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record<string, unknown>));
|
||||
return NextResponse.json({
|
||||
profiles,
|
||||
limit: MAX_SAVED_SUBJECTS,
|
||||
|
||||
@@ -183,8 +183,10 @@ export async function POST(request: Request) {
|
||||
synastryBirthPayload(globalBirthProfileFromAccountRow(selfResolved.row)),
|
||||
synastryBirthPayload(globalBirthProfileFromAccountRow(partnerResolved.row)),
|
||||
]);
|
||||
const selfChart = await postPython("/api/chart", selfPayload);
|
||||
const partnerChart = await postPython("/api/chart", partnerPayload);
|
||||
const [selfChart, partnerChart] = await Promise.all([
|
||||
postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true }),
|
||||
postPython("/api/chart", { ...partnerPayload, skip_vedastro_main_entry_overview: true }),
|
||||
]);
|
||||
const relationshipType = parsed.data.relationshipType === "business" || parsed.data.relationshipType === "family" || parsed.data.relationshipType === "general"
|
||||
? parsed.data.relationshipType
|
||||
: "romance";
|
||||
@@ -210,18 +212,20 @@ export async function POST(request: Request) {
|
||||
message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。",
|
||||
});
|
||||
}
|
||||
const selfD9 = await postPython("/api/varga_full", {
|
||||
...selfPayload,
|
||||
planets: selfChart.planets,
|
||||
ascendant: selfChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const partnerD9 = await postPython("/api/varga_full", {
|
||||
...partnerPayload,
|
||||
planets: partnerChart.planets,
|
||||
ascendant: partnerChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const [selfD9, partnerD9] = await Promise.all([
|
||||
postPython("/api/varga_full", {
|
||||
...selfPayload,
|
||||
planets: selfChart.planets,
|
||||
ascendant: selfChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
}),
|
||||
postPython("/api/varga_full", {
|
||||
...partnerPayload,
|
||||
planets: partnerChart.planets,
|
||||
ascendant: partnerChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
}),
|
||||
]);
|
||||
const synastry = await postPython("/api/synastry", {
|
||||
male_moon: moonLongitude(selfChart),
|
||||
female_moon: moonLongitude(partnerChart),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
parseRectificationQuestionnaire,
|
||||
@@ -34,7 +34,7 @@ export type JourneyLoadClient = {
|
||||
from(table: string): { select(columns: string): JourneyLoadQuery };
|
||||
};
|
||||
|
||||
export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient {
|
||||
export function createJourneyLoadClient(supabase: DataClient): JourneyLoadClient {
|
||||
return {
|
||||
from(table) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import type {
|
||||
BirthTimeJourneyStore,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
@@ -24,7 +24,7 @@ import { createDynamicScoringJobStore } from "./birth-time-dynamic-scoring-job-s
|
||||
export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
|
||||
|
||||
export function createSupabaseBirthTimeJourneyStore(
|
||||
supabase: SupabaseClient,
|
||||
supabase: DataClient,
|
||||
now: () => Date = () => new Date(),
|
||||
): BirthTimeJourneyStore {
|
||||
const loadClient = createJourneyLoadClient(supabase);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { z } from "zod";
|
||||
import type { JourneySnapshot } from "./birth-time-journey.ts";
|
||||
import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts";
|
||||
@@ -56,7 +56,7 @@ function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly Evide
|
||||
}
|
||||
|
||||
export function createJourneyTurnPersistence(
|
||||
supabase: SupabaseClient,
|
||||
supabase: DataClient,
|
||||
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { z } from "zod";
|
||||
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
|
||||
import type {
|
||||
@@ -58,7 +58,7 @@ async function requireReloaded(
|
||||
}
|
||||
|
||||
export function createSupabaseScoringJobStore(
|
||||
supabase: SupabaseClient,
|
||||
supabase: DataClient,
|
||||
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
|
||||
): ScoringStoreMethods {
|
||||
return {
|
||||
@@ -91,18 +91,17 @@ export function createSupabaseScoringJobStore(
|
||||
},
|
||||
|
||||
async claimScoringJob(identity) {
|
||||
const { data, error } = await supabase
|
||||
.rpc("claim_birth_time_scoring_job", {
|
||||
p_user_id: identity.userId,
|
||||
p_case_id: identity.caseId,
|
||||
p_job_id: identity.jobId,
|
||||
p_evidence_fingerprint: identity.evidenceFingerprint,
|
||||
p_algorithm_version: identity.algorithmVersion,
|
||||
p_now: identity.now,
|
||||
})
|
||||
.single();
|
||||
const { data, error } = await supabase.rpc("claim_birth_time_scoring_job", {
|
||||
p_user_id: identity.userId,
|
||||
p_case_id: identity.caseId,
|
||||
p_job_id: identity.jobId,
|
||||
p_evidence_fingerprint: identity.evidenceFingerprint,
|
||||
p_algorithm_version: identity.algorithmVersion,
|
||||
p_now: identity.now,
|
||||
});
|
||||
if (error) throw mapScoringRpcError(error.message, error.code);
|
||||
const parsed = claimSchema.safeParse(data);
|
||||
const row = Array.isArray(data) ? data[0] ?? null : data;
|
||||
const parsed = claimSchema.safeParse(row);
|
||||
if (!parsed.success) throw new BirthTimeJourneyStoreError("load_case");
|
||||
return {
|
||||
kind: parsed.data.claim_state,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type DataQueryError = Readonly<{ message: string; code?: string }>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient<any>; phase 2 narrows per table
|
||||
type UntypedData = any;
|
||||
|
||||
export type DataQueryResult = Readonly<{
|
||||
data: UntypedData;
|
||||
error: DataQueryError | null;
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
export interface DataQueryBuilder extends PromiseLike<DataQueryResult> {
|
||||
select(columns?: string): DataQueryBuilder;
|
||||
insert(value: Record<string, unknown> | readonly Record<string, unknown>[]): DataQueryBuilder;
|
||||
upsert(
|
||||
value: Record<string, unknown> | readonly Record<string, unknown>[],
|
||||
options?: { onConflict?: string },
|
||||
): DataQueryBuilder;
|
||||
update(values: Record<string, unknown>): DataQueryBuilder;
|
||||
delete(options?: { count?: string }): DataQueryBuilder;
|
||||
eq(column: string, value: unknown): DataQueryBuilder;
|
||||
neq(column: string, value: unknown): DataQueryBuilder;
|
||||
gt(column: string, value: unknown): DataQueryBuilder;
|
||||
lte(column: string, value: unknown): DataQueryBuilder;
|
||||
like(column: string, value: unknown): DataQueryBuilder;
|
||||
in(column: string, value: readonly unknown[]): DataQueryBuilder;
|
||||
is(column: string, value: unknown): DataQueryBuilder;
|
||||
not(column: string, operator: string, value: unknown): DataQueryBuilder;
|
||||
or(expression: string): DataQueryBuilder;
|
||||
order(column: string, options?: { ascending?: boolean }): DataQueryBuilder;
|
||||
limit(value: number): DataQueryBuilder;
|
||||
abortSignal(signal: AbortSignal): DataQueryBuilder;
|
||||
single(): Promise<DataQueryResult>;
|
||||
maybeSingle(): Promise<DataQueryResult>;
|
||||
}
|
||||
|
||||
export interface DataClient {
|
||||
readonly auth: Readonly<{
|
||||
getUser: () => Promise<{
|
||||
data: { user: { id: string; email?: string | null } | null };
|
||||
error: unknown;
|
||||
}>;
|
||||
}>;
|
||||
from(table: string): DataQueryBuilder;
|
||||
rpc(fn: string, args?: Readonly<Record<string, unknown>>): Promise<DataQueryResult>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import type { DataClient, DataQueryBuilder } from "./data-client";
|
||||
|
||||
type LocalIdentity = Readonly<{ id: string; email: string | null }> | null;
|
||||
export type LocalDatabaseRole = "authenticated" | "service_role";
|
||||
@@ -457,7 +458,7 @@ export function queryValue(type: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
class LocalPostgresQueryBuilder implements DataQueryBuilder {
|
||||
private selectedColumns: string[] | null = null;
|
||||
private mutation: Mutation | null = null;
|
||||
private readonly filters: Filter[] = [];
|
||||
@@ -863,7 +864,7 @@ function castType(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LocalPostgresDataClient {
|
||||
export class LocalPostgresDataClient implements DataClient {
|
||||
readonly auth: Readonly<{
|
||||
getUser: () => Promise<
|
||||
Readonly<{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts";
|
||||
import { declaredClockRange } from "../../declared-birth-window.ts";
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
type RectificationEntrySummary,
|
||||
} from "./open-request.ts";
|
||||
|
||||
type AccountingClient = SupabaseClient;
|
||||
type AccountingClient = DataClient;
|
||||
|
||||
export type V9BaselineSnapshot = Readonly<{
|
||||
birth_date: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { RectificationCaseServiceError } from "./case-service.ts";
|
||||
import type { OpenRectificationCaseRequest } from "./open-request.ts";
|
||||
|
||||
@@ -17,7 +17,7 @@ const domainsBySessionTheme: Readonly<Record<string, string>> = {
|
||||
|
||||
/** Only an owned persisted consultation is a product-domain source. */
|
||||
export async function resolveRectificationProductDomain(
|
||||
client: SupabaseClient,
|
||||
client: DataClient,
|
||||
userId: string,
|
||||
request: OpenRectificationCaseRequest,
|
||||
): Promise<string> {
|
||||
|
||||
@@ -44,7 +44,7 @@ import { decisionStateFingerprint } from "../core/decision-fingerprint";
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Minimal structural RPC client. SupabaseClient satisfies this; tests can
|
||||
* Minimal structural RPC client. DataClient satisfies this; tests can
|
||||
* pass a fake with a single rpc() method.
|
||||
*/
|
||||
export type RectificationRpcClient = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
|
||||
/** C3: whether the offer card may be shown, decided by the server and never by the model. */
|
||||
export async function decideRectificationOffer(input: {
|
||||
accounting: SupabaseClient;
|
||||
accounting: DataClient;
|
||||
userId: string;
|
||||
subjectRole: "self" | "other";
|
||||
modelId: string;
|
||||
@@ -42,7 +42,7 @@ export async function decideRectificationOffer(input: {
|
||||
|
||||
type SessionRow = { id: string; messages: unknown; session_type?: string | null };
|
||||
|
||||
async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise<SessionRow | null> {
|
||||
async function readConsultationSession(client: DataClient, userId: string, sessionId: string): Promise<SessionRow | null> {
|
||||
const { data, error } = await client
|
||||
.from("chat_sessions")
|
||||
.select("id,messages,session_type")
|
||||
@@ -54,7 +54,7 @@ async function readConsultationSession(client: SupabaseClient, userId: string, s
|
||||
return (row.session_type ?? "consultation") === "consultation" ? row : null;
|
||||
}
|
||||
|
||||
async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
|
||||
async function writeMessages(client: DataClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
|
||||
const { error } = await client
|
||||
.from("chat_sessions")
|
||||
.update({ messages })
|
||||
@@ -71,7 +71,7 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
|
||||
* Does not touch consultation_requests.
|
||||
*/
|
||||
export async function persistOpenedDirectOffer(input: {
|
||||
client: SupabaseClient;
|
||||
client: DataClient;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
sentence: string;
|
||||
@@ -96,7 +96,7 @@ export async function persistOpenedDirectOffer(input: {
|
||||
|
||||
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
|
||||
export async function bindRectificationSource(input: {
|
||||
client: SupabaseClient;
|
||||
client: DataClient;
|
||||
userId: string;
|
||||
sourceSessionId: string;
|
||||
caseId: string;
|
||||
@@ -118,7 +118,7 @@ const SOURCE_SEARCH_LIMIT = 20;
|
||||
* from history has none.
|
||||
*/
|
||||
export async function findRectificationSource(
|
||||
client: SupabaseClient,
|
||||
client: DataClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
): Promise<RectificationSource | null> {
|
||||
@@ -139,8 +139,8 @@ export async function findRectificationSource(
|
||||
* never from the browser.
|
||||
*/
|
||||
export async function returnToRectificationSource(input: {
|
||||
client: SupabaseClient;
|
||||
accounting: SupabaseClient;
|
||||
client: DataClient;
|
||||
accounting: DataClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
now?: () => Date;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core";
|
||||
import {
|
||||
getSupabaseUrl,
|
||||
SupabaseConfigurationError,
|
||||
} from "./config";
|
||||
|
||||
export function createAdminSupabaseClient() {
|
||||
export function createAdminSupabaseClient(): DataClient {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const connectionString = process.env.SERVICE_DATABASE_URL?.trim();
|
||||
if (!connectionString) {
|
||||
@@ -18,7 +19,7 @@ export function createAdminSupabaseClient() {
|
||||
connectionString,
|
||||
null,
|
||||
"service_role",
|
||||
) as unknown as SupabaseClient;
|
||||
) as DataClient;
|
||||
}
|
||||
const url = getSupabaseUrl();
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
@@ -26,9 +27,10 @@ export function createAdminSupabaseClient() {
|
||||
throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
|
||||
}
|
||||
|
||||
// 回滚分支,运行时形状是超集
|
||||
return createClient(url, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}) as unknown as DataClient;
|
||||
}
|
||||
|
||||
export function isAdminEmail(email: string | null | undefined) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import { getIdentityAuthServices } from "@/modules/identity/auth";
|
||||
@@ -11,7 +11,7 @@ import { getSupabasePublicConfig } from "./config";
|
||||
|
||||
export async function createServerSupabaseClient(options?: {
|
||||
readonly disableCookieCache?: boolean;
|
||||
}) {
|
||||
}): Promise<DataClient> {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await readIdentitySession(
|
||||
@@ -22,11 +22,12 @@ export async function createServerSupabaseClient(options?: {
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
|
||||
session ? { id: session.user.id, email: session.user.email } : null,
|
||||
) as unknown as SupabaseClient;
|
||||
) as DataClient;
|
||||
}
|
||||
const { url, anonKey } = getSupabasePublicConfig();
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// 回滚分支,运行时形状是超集
|
||||
return createServerClient(url, anonKey, {
|
||||
cookies: {
|
||||
getAll: () => cookieStore.getAll(),
|
||||
@@ -40,5 +41,5 @@ export async function createServerSupabaseClient(options?: {
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as unknown as DataClient;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { windowIntervalsWidth } from "../lib/rectification-agentic/core/candidate-window.ts";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DataClient } from "@/lib/db/data-client";
|
||||
import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import {
|
||||
canonicalToolInputFingerprint,
|
||||
@@ -196,7 +196,7 @@ export type RectificationV9Context = Readonly<{
|
||||
turnId: string;
|
||||
attemptId?: string;
|
||||
userMessage?: string | null;
|
||||
accounting: SupabaseClient;
|
||||
accounting: DataClient;
|
||||
engineBase?: string;
|
||||
decision?: RectificationDecision;
|
||||
productDelivery?: import("../lib/rectification-agentic/v9/product-delivery.ts").ProductDelivery | null;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const roots = ["src/app/api", "src/lib"];
|
||||
// Empty on purpose. A call site that cannot send skip must be listed here with a reason.
|
||||
const allowlist: string[] = [];
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const found: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = path.join(dir, name);
|
||||
if (statSync(full).isDirectory()) found.push(...walk(full));
|
||||
else if (name.endsWith(".ts") || name.endsWith(".tsx")) found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
test("python /api/chart bodies from app/api and lib send skip", () => {
|
||||
const failures: string[] = [];
|
||||
for (const root of roots) {
|
||||
for (const file of walk(path.join(frontendRoot, root))) {
|
||||
const rel = path.relative(frontendRoot, file).replaceAll("\\", "/");
|
||||
if (allowlist.includes(rel)) continue;
|
||||
const text = readFileSync(file, "utf8");
|
||||
const re = /["']\/api\/chart["']/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text))) {
|
||||
const idx = match.index;
|
||||
const before = text.slice(Math.max(0, idx - 120), idx);
|
||||
if (!/postPython\s*\(|callEngine\s*\(|fetchEngine\s*\(|fetch\s*\(/.test(before)) continue;
|
||||
const window = text.slice(Math.max(0, idx - 40), idx + 420);
|
||||
const sendsSkip = window.includes("skip_vedastro_main_entry_overview: true")
|
||||
|| window.includes("transitChartBody(")
|
||||
|| (/["']\/api\/chart["']\s*,\s*payload/.test(window) && text.includes("skip_vedastro_main_entry_overview: true"));
|
||||
if (!sendsSkip) failures.push(`${rel}:${text.slice(0, idx).split("\n").length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import type { DataClient } from "../src/lib/db/data-client.ts";
|
||||
import { birthTimeScoringAlgorithmVersion } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { createSupabaseScoringJobStore } from "../src/lib/birth-time-scoring-job-store.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
}).status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const ids = {
|
||||
user: "e1111111-1111-4111-8111-111111111111",
|
||||
caseId: "e2222222-2222-4222-8222-222222222222",
|
||||
job: "e3333333-3333-4333-8333-333333333333",
|
||||
shapeJob: "e4444444-4444-4444-8444-444444444444",
|
||||
};
|
||||
|
||||
const fingerprint = "claim-shape-fingerprint";
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
const identity = {
|
||||
userId: ids.user,
|
||||
caseId: ids.caseId,
|
||||
jobId: ids.job,
|
||||
evidenceFingerprint: fingerprint,
|
||||
algorithmVersion: birthTimeScoringAlgorithmVersion,
|
||||
now: "2026-10-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
test("claimScoringJob reads the first row when rpc returns an array", async () => {
|
||||
const rpc = async () => ({
|
||||
data: [{ claim_state: "claimed", algorithm_version: birthTimeScoringAlgorithmVersion }],
|
||||
error: null,
|
||||
});
|
||||
const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
|
||||
const claim = await store.claimScoringJob(identity);
|
||||
assert.deepEqual(claim, {
|
||||
kind: "claimed",
|
||||
algorithmVersion: birthTimeScoringAlgorithmVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test("claimScoringJob also accepts the single-object rpc shape", async () => {
|
||||
const rpc = async () => ({
|
||||
data: { claim_state: "processing", algorithm_version: birthTimeScoringAlgorithmVersion },
|
||||
error: null,
|
||||
});
|
||||
const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
|
||||
const claim = await store.claimScoringJob(identity);
|
||||
assert.deepEqual(claim, {
|
||||
kind: "processing",
|
||||
algorithmVersion: birthTimeScoringAlgorithmVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test("claim_birth_time_scoring_job returns an array and claimScoringJob accepts it", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(
|
||||
migration.status,
|
||||
0,
|
||||
JSON.stringify({
|
||||
status: migration.status,
|
||||
stderr: migration.stderr,
|
||||
stdoutTail: String(migration.stdout).slice(-1500),
|
||||
}),
|
||||
);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`insert into identity.users (id, name, email, email_verified, email_verified_at)
|
||||
values (${sqlLiteral(ids.user)}, 'Claim Shape', 'claim-shape@example.com', true, now());`,
|
||||
);
|
||||
fixture.psql(`
|
||||
insert into public.birth_time_rectification_cases (
|
||||
id, user_id, status, reported_date, source
|
||||
) values (
|
||||
${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, 'rectifying', '1990-01-01', 'family_exact'
|
||||
);
|
||||
insert into public.birth_time_rectification_scoring_jobs (
|
||||
id, case_id, user_id, evidence_fingerprint, algorithm_version, status, expires_at
|
||||
) values (
|
||||
${sqlLiteral(ids.job)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
|
||||
${sqlLiteral(fingerprint)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
|
||||
'pending', '2026-10-15T01:00:00Z'
|
||||
), (
|
||||
${sqlLiteral(ids.shapeJob)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
|
||||
${sqlLiteral(`${fingerprint}-shape`)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
|
||||
'pending', '2026-10-15T01:00:00Z'
|
||||
);
|
||||
update public.birth_time_rectification_cases
|
||||
set journey_protocol = 'legacy-guided-v1',
|
||||
turn_state = ${sqlLiteral(JSON.stringify({
|
||||
nextAction: { kind: "score_pending", jobId: ids.shapeJob },
|
||||
}))}::jsonb
|
||||
where id = ${sqlLiteral(ids.caseId)};
|
||||
`);
|
||||
|
||||
const client = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
const direct = await client.rpc("claim_birth_time_scoring_job", {
|
||||
p_user_id: ids.user,
|
||||
p_case_id: ids.caseId,
|
||||
p_job_id: ids.shapeJob,
|
||||
p_evidence_fingerprint: `${fingerprint}-shape`,
|
||||
p_algorithm_version: birthTimeScoringAlgorithmVersion,
|
||||
p_now: identity.now,
|
||||
});
|
||||
assert.equal(direct.error, null, direct.error?.message);
|
||||
assert.equal(Array.isArray(direct.data), true);
|
||||
assert.equal(direct.data[0]?.claim_state, "claimed");
|
||||
|
||||
fixture.psql(`
|
||||
update public.birth_time_rectification_cases
|
||||
set turn_state = ${sqlLiteral(JSON.stringify({
|
||||
nextAction: { kind: "score_pending", jobId: ids.job },
|
||||
}))}::jsonb
|
||||
where id = ${sqlLiteral(ids.caseId)};
|
||||
`);
|
||||
const store = createSupabaseScoringJobStore(client, async () => null);
|
||||
const claim = await store.claimScoringJob(identity);
|
||||
assert.deepEqual(claim, {
|
||||
kind: "claimed",
|
||||
algorithmVersion: birthTimeScoringAlgorithmVersion,
|
||||
});
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const partnerId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function runRoute(script: string): { romance: Record<string, unknown>; business: Record<string, unknown>; maxChart: number; maxVarga: number; chartSkip: boolean } {
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--experimental-test-module-mocks",
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
script,
|
||||
], { cwd: frontendRoot, encoding: "utf8", timeout: 20000 });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const line = result.stdout.trim().split("\n").at(-1) ?? "{}";
|
||||
return JSON.parse(line);
|
||||
}
|
||||
|
||||
test("synastry charts send skip concurrently and keep the stubbed JSON", () => {
|
||||
const script = String.raw`
|
||||
import { mock } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
|
||||
mock.module("server-only", { namedExports: {} });
|
||||
mock.module("@/lib/account-deletion-server", { namedExports: { refuseWhenAccountDeletionPending: async () => null } });
|
||||
mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => true } });
|
||||
mock.module("@/lib/request-rate-limit", { namedExports: { consumeUserRequestRateLimit: () => ({ ok: true, retryAfterSeconds: 1 }) } });
|
||||
mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) } }) } });
|
||||
mock.module("@/lib/subject-birth", { namedExports: {
|
||||
ConsultationSubjectError: class ConsultationSubjectError extends Error {},
|
||||
loadSubjectBirth: async () => ({ chartable: true, row: { name: "fixture" } }),
|
||||
} });
|
||||
mock.module("@/lib/server-owned-birth-profile", { namedExports: { globalBirthProfileFromAccountRow: (row) => row } });
|
||||
mock.module("@/lib/global-birth-payloads", { namedExports: {
|
||||
synastryBirthPayload: async () => ({ year: 1990, month: 1, day: 1, hour: 12, minute: 0, lat: 28.6139, lon: 77.209, tz: 5.5, ayanamsa: "lahiri" }),
|
||||
} });
|
||||
const chartBodies = [];
|
||||
const maxInflight = { chart: 0, varga: 0 };
|
||||
const inflight = { chart: 0, varga: 0 };
|
||||
const waiters = { chart: [], varga: [] };
|
||||
globalThis.fetch = async (inputUrl, init) => {
|
||||
const url = String(inputUrl);
|
||||
const body = JSON.parse(String(init.body));
|
||||
const kind = url.endsWith("/api/chart") ? "chart" : url.endsWith("/api/varga_full") ? "varga" : "other";
|
||||
if (kind === "chart") chartBodies.push(body);
|
||||
if (kind === "chart" || kind === "varga") {
|
||||
inflight[kind] += 1;
|
||||
maxInflight[kind] = Math.max(maxInflight[kind], inflight[kind]);
|
||||
await new Promise((resolve) => {
|
||||
waiters[kind].push(resolve);
|
||||
if (waiters[kind].length >= 2) {
|
||||
const pending = waiters[kind].splice(0, 2);
|
||||
pending.forEach((fn) => fn());
|
||||
}
|
||||
});
|
||||
inflight[kind] -= 1;
|
||||
}
|
||||
if (url.endsWith("/api/chart")) {
|
||||
return Response.json({
|
||||
planets: { Moon: { lon: 12.5, sign: "Aries", nakshatra: "Ashwini", nakshatra_pada: 1, nakshatra_lord: "Ketu" } },
|
||||
ascendant: { sign: "Leo" },
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/varga_full")) {
|
||||
const divisions = body.divisions || [];
|
||||
if (divisions.includes("D9")) {
|
||||
return Response.json({ source: "engine", result: { D9_Navamsa: { ascendant: { sign: "Virgo" }, planets: { Moon: { sign: "Cancer" }, Venus: { sign: "Taurus" }, Mars: { sign: "Aries" } } } } });
|
||||
}
|
||||
return Response.json({ source: "engine", result: {
|
||||
D2_Hora: { planets: { Moon: { sign: "Taurus" } } },
|
||||
D10_Dasamsa: { ascendant: { sign: "Libra" } },
|
||||
D11_Rudramsa: { planets: { Sun: { sign: "Capricorn" } } },
|
||||
} });
|
||||
}
|
||||
if (url.endsWith("/api/synastry")) return Response.json({ total_score: 18, max_score: 36 });
|
||||
return Response.json({}, { status: 500 });
|
||||
};
|
||||
const { POST } = await import(moduleUrl("src/app/api/synastry/route.ts"));
|
||||
const romanceRes = await POST(new Request("https://example.test/api/synastry", { method: "POST", body: JSON.stringify({ partnerChartProfileId: "${partnerId}", relationshipType: "romance" }) }));
|
||||
const romance = await romanceRes.json();
|
||||
const businessRes = await POST(new Request("https://example.test/api/synastry", { method: "POST", body: JSON.stringify({ partnerChartProfileId: "${partnerId}", relationshipType: "business" }) }));
|
||||
const business = await businessRes.json();
|
||||
console.log(JSON.stringify({ romance, business, maxChart: maxInflight.chart, maxVarga: maxInflight.varga, chartSkip: chartBodies.every((body) => body.skip_vedastro_main_entry_overview === true) && chartBodies.length === 4 }));
|
||||
`;
|
||||
const got = runRoute(script);
|
||||
assert.equal(got.maxChart, 2);
|
||||
assert.equal(got.maxVarga, 2);
|
||||
assert.equal(got.chartSkip, true);
|
||||
assert.equal(got.romance.status, "ok");
|
||||
assert.equal(got.romance.method, "ashtakoot_plus_moon_nakshatra_d9");
|
||||
assert.deepEqual(got.romance.selfChart, {
|
||||
moon: { sign: "Aries", nakshatra: "Ashwini", pada: 1, lord: "Ketu", longitude: 12.5 },
|
||||
d9: { ascendant: { sign: "Virgo" }, moon: { sign: "Cancer" }, venus: { sign: "Taurus" }, mars: { sign: "Aries" }, source: "engine" },
|
||||
});
|
||||
assert.equal((got.romance.relationshipReport as { scoreBand?: string }).scoreBand, "mixed");
|
||||
assert.equal(got.business.status, "ok");
|
||||
assert.equal(got.business.method, "d2_d10_d11_business_screening_partial");
|
||||
assert.equal((got.business.relationshipReport as { scoreBand?: string }).scoreBand, "not_scored");
|
||||
});
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,6 +23,34 @@ from typing import Any, Callable
|
||||
|
||||
ENGINE = "PyJHora/JHora"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_jhora_spec_cache: bool | None = None
|
||||
|
||||
|
||||
def reset_jhora_import_cache() -> None:
|
||||
"""Tests reset the process-local "is PyJHora installed?" answer."""
|
||||
global _jhora_spec_cache
|
||||
_jhora_spec_cache = None
|
||||
|
||||
|
||||
def _jhora_importable() -> bool:
|
||||
global _jhora_spec_cache
|
||||
if _jhora_spec_cache is None:
|
||||
_jhora_spec_cache = importlib.util.find_spec("jhora") is not None
|
||||
return _jhora_spec_cache
|
||||
|
||||
|
||||
def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Same dict the child returns when ``import jhora`` raises ModuleNotFoundError."""
|
||||
copied = dict(settings)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError",
|
||||
"settings": copied,
|
||||
"node_mode": {
|
||||
"requested": copied.get("node_mode"),
|
||||
"status": "unsupported_by_replay_adapter",
|
||||
},
|
||||
}
|
||||
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
|
||||
_YEAR_LORD_MODULES = (
|
||||
"jhora.horoscope.dhasa.annual.mudda",
|
||||
@@ -139,6 +168,10 @@ def _run_isolated_tajaka_year_lord(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
replay = _no_jhora_tajaka_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
@@ -276,6 +309,10 @@ def _run_isolated_annual_replay(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
replay = _no_jhora_annual_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
@@ -391,6 +428,18 @@ def _annual_worker_result(
|
||||
}
|
||||
|
||||
|
||||
def _no_jhora_annual_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Same dict the annual child returns when ``import jhora`` raises ModuleNotFoundError."""
|
||||
copied = dict(settings)
|
||||
node_mode = {"requested": copied.get("node_mode"), "status": "unsupported_by_replay_adapter"}
|
||||
reason = "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError"
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
||||
settings=copied,
|
||||
node_mode=node_mode,
|
||||
)
|
||||
|
||||
|
||||
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
||||
|
||||
@@ -8,13 +8,17 @@ must not call this gate.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_CONCURRENCY = 2
|
||||
DEFAULT_RETRY_AFTER_SECONDS = 2
|
||||
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
|
||||
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
|
||||
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
|
||||
_LOG = logging.getLogger("jyotish.heavy_compute")
|
||||
|
||||
HEAVY_COMPUTE_PATHS = frozenset(
|
||||
{
|
||||
@@ -46,6 +50,26 @@ HEAVY_COMPUTE_PATHS = frozenset(
|
||||
_state_lock = threading.Lock()
|
||||
_semaphore: threading.BoundedSemaphore | None = None
|
||||
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
|
||||
_limit = DEFAULT_CONCURRENCY
|
||||
_holders: list[dict] = []
|
||||
_holder_seq = 0
|
||||
|
||||
|
||||
class _HeldSlot:
|
||||
"""One acquired heavy-compute permit, paired with the path that holds it."""
|
||||
|
||||
def __init__(self, semaphore: threading.BoundedSemaphore, holder_id: int) -> None:
|
||||
self._semaphore = semaphore
|
||||
self._holder_id = holder_id
|
||||
self._released = False
|
||||
|
||||
def release(self) -> None:
|
||||
with _state_lock:
|
||||
if self._released:
|
||||
return
|
||||
self._released = True
|
||||
_holders[:] = [item for item in _holders if item["id"] != self._holder_id]
|
||||
self._semaphore.release()
|
||||
|
||||
|
||||
class HeavyComputeBusy(RuntimeError):
|
||||
@@ -80,7 +104,7 @@ def reset_heavy_compute_gate(
|
||||
retry_after_seconds: int | None = None,
|
||||
) -> None:
|
||||
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
|
||||
global _semaphore, _retry_after_seconds
|
||||
global _semaphore, _retry_after_seconds, _limit, _holders
|
||||
resolved_limit = (
|
||||
limit
|
||||
if limit is not None
|
||||
@@ -93,31 +117,96 @@ def reset_heavy_compute_gate(
|
||||
)
|
||||
with _state_lock:
|
||||
_retry_after_seconds = resolved_retry
|
||||
_limit = resolved_limit
|
||||
_holders = []
|
||||
_semaphore = threading.BoundedSemaphore(resolved_limit)
|
||||
|
||||
|
||||
def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]:
|
||||
global _semaphore, _retry_after_seconds
|
||||
global _semaphore, _retry_after_seconds, _limit
|
||||
if _semaphore is None:
|
||||
limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
||||
_limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
||||
_retry_after_seconds = _parse_positive_int(
|
||||
os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS
|
||||
)
|
||||
_semaphore = threading.BoundedSemaphore(limit)
|
||||
_semaphore = threading.BoundedSemaphore(_limit)
|
||||
return _semaphore, _retry_after_seconds
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot(path: str) -> threading.BoundedSemaphore | None:
|
||||
def _holder_view(now: float) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"path": item["path"],
|
||||
"held_seconds": round(max(now - item["started"], 0.0), 3),
|
||||
}
|
||||
for item in _holders
|
||||
]
|
||||
|
||||
|
||||
def _acquire_recorded(path: str) -> _HeldSlot:
|
||||
"""Take one permit and remember which path holds it. Fail fast when full."""
|
||||
global _holder_seq
|
||||
with _state_lock:
|
||||
semaphore, retry_after = _ensure_locked()
|
||||
if not semaphore.acquire(blocking=False):
|
||||
snapshot = _holder_view(time.monotonic())
|
||||
failed = True
|
||||
slot = None
|
||||
else:
|
||||
_holder_seq += 1
|
||||
holder_id = _holder_seq
|
||||
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
|
||||
failed = False
|
||||
snapshot = []
|
||||
slot = _HeldSlot(semaphore, holder_id)
|
||||
if failed:
|
||||
_LOG.warning(
|
||||
"heavy_compute_busy rejected_path=%s holders=%s",
|
||||
path,
|
||||
snapshot,
|
||||
)
|
||||
raise HeavyComputeBusy(retry_after)
|
||||
assert slot is not None
|
||||
return slot
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
|
||||
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
|
||||
if not is_heavy_compute_path(path):
|
||||
return None
|
||||
return _acquire_recorded(path)
|
||||
|
||||
|
||||
def _chart_overview_gate_enabled() -> bool:
|
||||
return os.environ.get(ENV_GATE_CHART_OVERVIEW, "").strip() == "1"
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot_for_request(path: str, body: dict | None) -> _HeldSlot | None:
|
||||
"""Path gate, plus the chart-overview switch and the warm-consultation exception.
|
||||
|
||||
``/api/chart`` stays out of ``HEAVY_COMPUTE_PATHS``. It takes a slot only when
|
||||
``JYOTISH_GATE_CHART_OVERVIEW=1``, the body does not skip the VedAstro overview,
|
||||
and the body is not an async enqueue. Default is off.
|
||||
"""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
if path == "/api/consultation_workflow" and payload.get("consult_card_warm_only") is True:
|
||||
return None
|
||||
if path == "/api/chart" and _chart_overview_gate_enabled():
|
||||
if payload.get("async") or payload.get("enqueue"):
|
||||
return None
|
||||
if payload.get("skip_vedastro_main_entry_overview"):
|
||||
return None
|
||||
return _acquire_recorded(path)
|
||||
return acquire_heavy_compute_slot(path)
|
||||
|
||||
|
||||
def heavy_compute_status() -> dict:
|
||||
"""Process-local counts only. No request body and no user identity."""
|
||||
with _state_lock:
|
||||
semaphore, retry_after = _ensure_locked()
|
||||
if not semaphore.acquire(blocking=False):
|
||||
raise HeavyComputeBusy(retry_after)
|
||||
return semaphore
|
||||
_ensure_locked()
|
||||
return {"limit": _limit, "in_use": len(_holders)}
|
||||
|
||||
|
||||
def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None:
|
||||
def release_heavy_compute_slot(slot: _HeldSlot | None) -> None:
|
||||
if slot is not None:
|
||||
slot.release()
|
||||
|
||||
@@ -122,13 +122,13 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
try:
|
||||
from scripts.api_heavy_compute_gate import (
|
||||
HeavyComputeBusy,
|
||||
acquire_heavy_compute_slot,
|
||||
acquire_heavy_compute_slot_for_request,
|
||||
release_heavy_compute_slot,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from api_heavy_compute_gate import (
|
||||
HeavyComputeBusy,
|
||||
acquire_heavy_compute_slot,
|
||||
acquire_heavy_compute_slot_for_request,
|
||||
release_heavy_compute_slot,
|
||||
)
|
||||
|
||||
@@ -3345,14 +3345,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
try:
|
||||
self._enforce_request_security(require_json=True)
|
||||
body = self._read_json_body()
|
||||
# Chart-save warm builds today's packet on the same route. It must
|
||||
# not take a heavy-compute slot: the cap is 2, and a question asked
|
||||
# right after save would otherwise come back 429.
|
||||
warm_only = (
|
||||
path == '/api/consultation_workflow'
|
||||
and body.get('consult_card_warm_only') is True
|
||||
)
|
||||
compute_slot = None if warm_only else acquire_heavy_compute_slot(path)
|
||||
# Warm chart-save must not take a slot (BUG-1302). Chart overview
|
||||
# gating is off unless JYOTISH_GATE_CHART_OVERVIEW=1. Both live in
|
||||
# the gate module so this file only keeps the call.
|
||||
compute_slot = acquire_heavy_compute_slot_for_request(path, body)
|
||||
if path == '/api/location/resolve':
|
||||
city = str(body.get('city') or '').strip()
|
||||
city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'}
|
||||
|
||||
@@ -10762,7 +10762,25 @@ def _planet_snapshot(planets, planet_name):
|
||||
}
|
||||
|
||||
|
||||
_oracle_progress_cache: dict = {}
|
||||
|
||||
|
||||
def reset_oracle_progress_snapshot_cache():
|
||||
"""Tests clear the static-file validation cache."""
|
||||
_oracle_progress_cache.clear()
|
||||
|
||||
|
||||
def _oracle_progress_cache_key(oracle_file):
|
||||
try:
|
||||
stat = oracle_file.stat()
|
||||
except OSError:
|
||||
return None
|
||||
mtime = getattr(stat, 'st_mtime_ns', stat.st_mtime)
|
||||
return (str(oracle_file.resolve()), mtime, stat.st_size)
|
||||
|
||||
|
||||
def _oracle_progress_snapshot():
|
||||
cache_key = None
|
||||
try:
|
||||
import json
|
||||
import subprocess
|
||||
@@ -10772,6 +10790,9 @@ def _oracle_progress_snapshot():
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
oracle_file = root / 'references' / 'oracle' / 'dasha_shadbala_oracle_cases.json'
|
||||
cache_key = _oracle_progress_cache_key(oracle_file)
|
||||
if cache_key is not None and cache_key in _oracle_progress_cache:
|
||||
return _oracle_progress_cache[cache_key]
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
queue_path = Path(temp_dir) / 'oracle-queue.json'
|
||||
queue = subprocess.run(
|
||||
@@ -10797,7 +10818,7 @@ def _oracle_progress_snapshot():
|
||||
raise RuntimeError(validation.stderr.strip() or validation.stdout.strip())
|
||||
report = json.loads(validation.stdout)
|
||||
summary = report.get('summary', {})
|
||||
return {
|
||||
result = {
|
||||
'scope': 'external_oracle_evidence_validation',
|
||||
'collection_queue': 'external_oracle_collection_queue',
|
||||
'total_packets': summary.get('total_packets', 0),
|
||||
@@ -10809,7 +10830,7 @@ def _oracle_progress_snapshot():
|
||||
'boundary': report.get('boundary', 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.'),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
result = {
|
||||
'scope': 'external_oracle_evidence_validation',
|
||||
'collection_queue': 'external_oracle_collection_queue',
|
||||
'total_packets': 0,
|
||||
@@ -10820,6 +10841,9 @@ def _oracle_progress_snapshot():
|
||||
'promotion_rule': 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.',
|
||||
'boundary': f'Oracle progress unavailable: {exc}',
|
||||
}
|
||||
if cache_key is not None:
|
||||
_oracle_progress_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
# Combustion orbs of the full reading (Step 5 shadbala block). The Moon, Rahu
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""A/B for issue #64. A is this branch with the PyJHora short-circuit forced off
|
||||
(the staging control flow: always spawn). B is the short-circuit on.
|
||||
|
||||
Chart skip/concurrency is measured on the real chart call. The gate schemes
|
||||
O2/O3/O4 are simulated only and are not implemented.
|
||||
|
||||
Volatile fields dropped before the report byte compare:
|
||||
generated_at, created_at, timestamp, elapsed_seconds, elapsed_ms, duration_ms, request_id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
os.environ.setdefault("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
|
||||
os.environ.setdefault("JYOTISH_GATE_CHART_OVERVIEW", "0")
|
||||
|
||||
import annual_pyjhora_replay as replay # noqa: E402
|
||||
import tajika as tajika_top # noqa: E402
|
||||
from scripts.jyotish_api_server import JyotishAPIHandler # noqa: E402
|
||||
from scripts.jyotish_engine import reset_oracle_progress_snapshot_cache # noqa: E402
|
||||
from scripts.professional_report_reference import build_professional_report_reference # noqa: E402
|
||||
from scripts.tajika import reset_external_pyjhora_python_cache # noqa: E402
|
||||
|
||||
BIRTH = {
|
||||
"year": 1990,
|
||||
"month": 1,
|
||||
"day": 1,
|
||||
"hour": 12,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": 28.6139,
|
||||
"lon": 77.2090,
|
||||
"tz": 5.5,
|
||||
"ayanamsa": "lahiri",
|
||||
"node_mode": "true",
|
||||
}
|
||||
PARTNER = {**BIRTH, "year": 1992, "day": 2}
|
||||
VOLATILE = {"generated_at", "created_at", "timestamp", "elapsed_seconds", "elapsed_ms", "duration_ms", "request_id"}
|
||||
|
||||
|
||||
class _Counter:
|
||||
def __init__(self) -> None:
|
||||
self.n = 0
|
||||
self._real = subprocess.run
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def install(self) -> None:
|
||||
def _run(*args, **kwargs):
|
||||
with self._lock:
|
||||
self.n += 1
|
||||
return self._real(*args, **kwargs)
|
||||
|
||||
subprocess.run = _run # type: ignore[assignment]
|
||||
for module in list(sys.modules.values()):
|
||||
bound = getattr(module, "subprocess", None)
|
||||
if getattr(bound, "run", None) is self._real:
|
||||
bound.run = _run # type: ignore[union-attr]
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self.n = 0
|
||||
|
||||
|
||||
def _median(values: list[float]) -> float:
|
||||
return statistics.median(values) if values else 0.0
|
||||
|
||||
|
||||
def _strip(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _strip(item) for key, item in value.items() if key not in VOLATILE}
|
||||
if isinstance(value, list):
|
||||
return [_strip(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _canon(value) -> str:
|
||||
return json.dumps(_strip(value), sort_keys=True, ensure_ascii=True, default=str)
|
||||
|
||||
|
||||
def _chart_summary(chart: dict) -> dict:
|
||||
planets = chart.get("planets") if isinstance(chart, dict) else None
|
||||
moon = planets.get("Moon") if isinstance(planets, dict) else None
|
||||
lon = None
|
||||
if isinstance(moon, dict):
|
||||
lon = moon.get("lon", moon.get("longitude"))
|
||||
return {
|
||||
"ascendant": chart.get("ascendant") if isinstance(chart, dict) else None,
|
||||
"planets": planets,
|
||||
"moon_longitude": lon,
|
||||
}
|
||||
|
||||
|
||||
def _one_chart(body: dict) -> tuple[float, dict]:
|
||||
started = time.perf_counter()
|
||||
chart = object.__new__(JyotishAPIHandler)._compute_chart_sync(body)
|
||||
return time.perf_counter() - started, chart
|
||||
|
||||
|
||||
def _measure_charts(counter: _Counter) -> dict:
|
||||
serial_times = []
|
||||
concurrent_times = []
|
||||
serial_subs = []
|
||||
concurrent_subs = []
|
||||
same = True
|
||||
for _ in range(3):
|
||||
counter.reset()
|
||||
started = time.perf_counter()
|
||||
_, self_a = _one_chart(BIRTH)
|
||||
_, partner_a = _one_chart(PARTNER)
|
||||
serial_times.append(time.perf_counter() - started)
|
||||
serial_subs.append(counter.n)
|
||||
counter.reset()
|
||||
started = time.perf_counter()
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
self_b = pool.submit(_one_chart, {**BIRTH, "skip_vedastro_main_entry_overview": True})
|
||||
partner_b = pool.submit(_one_chart, {**PARTNER, "skip_vedastro_main_entry_overview": True})
|
||||
_, self_chart = self_b.result()
|
||||
_, partner_chart = partner_b.result()
|
||||
concurrent_times.append(time.perf_counter() - started)
|
||||
concurrent_subs.append(counter.n)
|
||||
same = same and _canon(_chart_summary(self_a)) == _canon(_chart_summary(self_chart))
|
||||
same = same and _canon(_chart_summary(partner_a)) == _canon(_chart_summary(partner_chart))
|
||||
return {
|
||||
"serial_seconds_median": round(_median(serial_times), 4),
|
||||
"concurrent_skip_seconds_median": round(_median(concurrent_times), 4),
|
||||
"serial_subprocesses_median": _median(serial_subs),
|
||||
"concurrent_skip_subprocesses_median": _median(concurrent_subs),
|
||||
"summary_bytes_match": same,
|
||||
}
|
||||
|
||||
|
||||
def _measure_report(counter: _Counter, *, spawn: bool) -> dict:
|
||||
times = []
|
||||
subs = []
|
||||
payload = None
|
||||
body = {**BIRTH, "format": "markdown", "edition": "full_data", "languages": ["zh", "en"], "include_fact_tables": True}
|
||||
for _ in range(3):
|
||||
replay.reset_jhora_import_cache()
|
||||
if spawn:
|
||||
replay._jhora_spec_cache = True
|
||||
reset_oracle_progress_snapshot_cache()
|
||||
reset_external_pyjhora_python_cache()
|
||||
tajika_top.reset_external_pyjhora_python_cache()
|
||||
counter.reset()
|
||||
started = time.perf_counter()
|
||||
payload = build_professional_report_reference(
|
||||
object.__new__(JyotishAPIHandler),
|
||||
body,
|
||||
)
|
||||
times.append(time.perf_counter() - started)
|
||||
subs.append(counter.n)
|
||||
return {
|
||||
"seconds_median": round(_median(times), 4),
|
||||
"subprocesses_median": _median(subs),
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def _simulate() -> list[dict]:
|
||||
"""One fixed minute. O2 adds a second pool and is the only scheme that touches BUG-717."""
|
||||
rng = random.Random(64)
|
||||
arrivals = [("report", index * 30.0, 13.0) for index in range(2)]
|
||||
arrivals += [("consult", rng.random() * 60.0, 0.2) for _ in range(20)]
|
||||
arrivals += [("score", rng.random() * 60.0, 8.0) for _ in range(6)]
|
||||
arrivals.sort(key=lambda item: item[1])
|
||||
|
||||
def run(mode: str) -> dict:
|
||||
heavy: list[tuple[float, str]] = []
|
||||
light: list[float] = []
|
||||
rejected = {"consult": 0, "report": 0, "score": 0}
|
||||
accepted = {"consult": 0, "report": 0, "score": 0}
|
||||
peak = 0
|
||||
|
||||
def occupy(at: float) -> tuple[int, int]:
|
||||
running = [item for item in heavy if item[0] > at]
|
||||
lights = [item for item in light if item > at]
|
||||
return len(running), len(lights)
|
||||
|
||||
for kind, arrival, duration in arrivals:
|
||||
heavy_n, light_n = occupy(arrival)
|
||||
heavy = [item for item in heavy if item[0] > arrival]
|
||||
light = [item for item in light if item > arrival]
|
||||
placed = False
|
||||
stamp = arrival
|
||||
if mode == "O2" and kind == "consult":
|
||||
if light_n < 2:
|
||||
light.append(arrival + duration)
|
||||
placed = True
|
||||
elif mode == "O3" and kind == "consult":
|
||||
start = arrival
|
||||
while start <= arrival + 1.5:
|
||||
heavy_n, _light_n = occupy(start)
|
||||
if heavy_n < 2:
|
||||
heavy.append((start + duration, kind))
|
||||
placed = True
|
||||
stamp = start
|
||||
break
|
||||
nxt = min(item[0] for item in heavy)
|
||||
if nxt > arrival + 1.5:
|
||||
break
|
||||
start = nxt
|
||||
elif mode == "O4" and kind != "consult":
|
||||
heavy_jobs = sum(1 for _end, slot in heavy if slot != "consult")
|
||||
if heavy_jobs < 1 and heavy_n < 2:
|
||||
heavy.append((arrival + duration, kind))
|
||||
placed = True
|
||||
else:
|
||||
if heavy_n < 2:
|
||||
heavy.append((arrival + duration, kind))
|
||||
placed = True
|
||||
if placed:
|
||||
accepted[kind] += 1
|
||||
else:
|
||||
rejected[kind] += 1
|
||||
running, lights = occupy(stamp)
|
||||
peak = max(peak, running + lights)
|
||||
|
||||
def rate(kind: str) -> str:
|
||||
total = accepted[kind] + rejected[kind]
|
||||
return "0" if total == 0 else f"{rejected[kind] / total:.2f}"
|
||||
|
||||
return {
|
||||
"scheme": mode,
|
||||
"consult_429": rate("consult"),
|
||||
"report_429": rate("report"),
|
||||
"score_429": rate("score"),
|
||||
"peak": peak,
|
||||
"touches_bug_717": mode == "O2",
|
||||
}
|
||||
|
||||
return [run(mode) for mode in ("O1", "O2", "O3", "O4")]
|
||||
|
||||
|
||||
def resolve_record_path(explicit: str | None) -> Path:
|
||||
"""Write the A/B JSON only where asked. The committed note stays untouched."""
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
directory = Path(tempfile.mkdtemp(prefix="api-chart-subprocess-ab-"))
|
||||
return directory / "api-chart-subprocess-gate-ab-20261013.json"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="A/B for issue #64 chart subprocess gate")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=None,
|
||||
help="Write the JSON record to this path. Omit to use a fresh temp directory, not the committed docs/testing file.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
counter = _Counter()
|
||||
counter.install()
|
||||
charts = _measure_charts(counter)
|
||||
forced = _measure_report(counter, spawn=True)
|
||||
native = _measure_report(counter, spawn=False)
|
||||
report_match = _canon(forced["payload"]) == _canon(native["payload"])
|
||||
schemes = _simulate()
|
||||
out = {
|
||||
"charts": charts,
|
||||
"report_forced_subprocess": {key: value for key, value in forced.items() if key != "payload"},
|
||||
"report_short_circuit": {key: value for key, value in native.items() if key != "payload"},
|
||||
"report_bytes_match": report_match,
|
||||
"schemes": schemes,
|
||||
"jhora_installed": False,
|
||||
}
|
||||
text = json.dumps(out, ensure_ascii=False, indent=2)
|
||||
dest = resolve_record_path(args.out)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(text + "\n", encoding="utf-8")
|
||||
print(dest, file=sys.stderr)
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1501,7 +1501,28 @@ print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
|
||||
return None
|
||||
|
||||
|
||||
_external_pyjhora_python_cached = False
|
||||
_external_pyjhora_python_value: Optional[str] = None
|
||||
|
||||
|
||||
def reset_external_pyjhora_python_cache() -> None:
|
||||
"""Tests and a fresh process both start with no remembered interpreter."""
|
||||
global _external_pyjhora_python_cached, _external_pyjhora_python_value
|
||||
_external_pyjhora_python_cached = False
|
||||
_external_pyjhora_python_value = None
|
||||
|
||||
|
||||
def _resolve_external_pyjhora_python() -> Optional[str]:
|
||||
"""Remember the lookup, including "not installed", for this process."""
|
||||
global _external_pyjhora_python_cached, _external_pyjhora_python_value
|
||||
if _external_pyjhora_python_cached:
|
||||
return _external_pyjhora_python_value
|
||||
_external_pyjhora_python_value = _lookup_external_pyjhora_python()
|
||||
_external_pyjhora_python_cached = True
|
||||
return _external_pyjhora_python_value
|
||||
|
||||
|
||||
def _lookup_external_pyjhora_python() -> Optional[str]:
|
||||
candidates = [
|
||||
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
|
||||
shutil.which('python3.14'),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Generated 2026-10-15 by calling the isolated workers with PyJHora absent
|
||||
(importlib.util.find_spec("jhora") is None on the Windows venv):
|
||||
|
||||
.venv\Scripts\python -c "import json; from scripts.annual_pyjhora_replay import _isolated_tajaka_worker, _isolated_annual_worker; req={'birth_julian_day':2447893.0,'place':{'name':'Delhi','latitude':28.6139,'longitude':77.209,'timezone':5.5,'elevation':0},'age':30,'settings':{'ayanamsa':'LAHIRI','node_mode':'mean'}}; print(json.dumps(_isolated_tajaka_worker(req), sort_keys=True)); print(json.dumps(_isolated_annual_worker(req), sort_keys=True, default=str))"
|
||||
|
||||
The parent adds request_hash after this body. The short-circuit must match this body.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"annual_chart_snapshot": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"annual_chart_speed_snapshot": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"effective_ayanamsa": null,
|
||||
"evidence_scope": "pyjhora_behavior_only",
|
||||
"mudda_dasha": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"node_mode": {
|
||||
"requested": "mean",
|
||||
"status": "unsupported_by_replay_adapter"
|
||||
},
|
||||
"parity_status": "not_multiengine_parity",
|
||||
"patyayini_dasha": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"sahams": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"settings": {
|
||||
"ayanamsa": "LAHIRI",
|
||||
"node_mode": "mean"
|
||||
},
|
||||
"solar_return_boundary": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
},
|
||||
"tajika_yogas": {
|
||||
"reason": "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError",
|
||||
"status": "blocked"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"node_mode": {
|
||||
"requested": "mean",
|
||||
"status": "unsupported_by_replay_adapter"
|
||||
},
|
||||
"reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError",
|
||||
"settings": {
|
||||
"ayanamsa": "LAHIRI",
|
||||
"node_mode": "mean"
|
||||
},
|
||||
"status": "blocked"
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Issue #64: skip useless report subprocesses and observe the heavy-compute gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import api_heavy_compute_gate as gate
|
||||
from scripts.annual_pyjhora_replay import (
|
||||
_run_isolated_annual_replay,
|
||||
_run_isolated_tajaka_year_lord,
|
||||
reset_jhora_import_cache,
|
||||
)
|
||||
from scripts.jyotish_engine import (
|
||||
_oracle_progress_snapshot,
|
||||
reset_oracle_progress_snapshot_cache,
|
||||
)
|
||||
from scripts.tajika import (
|
||||
_resolve_external_pyjhora_python,
|
||||
reset_external_pyjhora_python_cache,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures" / "api-chart-subprocess-gate"
|
||||
|
||||
|
||||
class _Place:
|
||||
name = "Delhi"
|
||||
latitude = 28.6139
|
||||
longitude = 77.209
|
||||
timezone = 5.5
|
||||
elevation = 0
|
||||
|
||||
|
||||
_PROFILE = {"ayanamsa": "LAHIRI", "node_mode": "mean"}
|
||||
|
||||
|
||||
def _without_hash(payload: dict) -> dict:
|
||||
body = dict(payload)
|
||||
body.pop("request_hash", None)
|
||||
return body
|
||||
|
||||
|
||||
def test_no_jhora_tajaka_skips_subprocess_and_matches_golden(monkeypatch) -> None:
|
||||
reset_jhora_import_cache()
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False)
|
||||
|
||||
def _forbid_subprocess(*_args, **_kwargs):
|
||||
raise AssertionError("subprocess.run must not run when jhora cannot be imported")
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess)
|
||||
result = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30)
|
||||
golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8"))
|
||||
assert result["request_hash"]
|
||||
assert _without_hash(result) == golden
|
||||
|
||||
|
||||
def test_no_jhora_annual_skips_subprocess_and_matches_golden(monkeypatch) -> None:
|
||||
reset_jhora_import_cache()
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False)
|
||||
|
||||
def _forbid_subprocess(*_args, **_kwargs):
|
||||
raise AssertionError("subprocess.run must not run when jhora cannot be imported")
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess)
|
||||
result = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30)
|
||||
golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8"))
|
||||
assert result["request_hash"]
|
||||
assert _without_hash(result) == golden
|
||||
|
||||
|
||||
def test_jhora_available_still_uses_subprocess(monkeypatch) -> None:
|
||||
reset_jhora_import_cache()
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: True)
|
||||
calls = {"n": 0}
|
||||
|
||||
class _Completed:
|
||||
returncode = 0
|
||||
stdout = json.dumps({"status": "partial_verified", "settings": {}})
|
||||
|
||||
def _fake_run(*_args, **_kwargs):
|
||||
calls["n"] += 1
|
||||
return _Completed()
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run)
|
||||
tajaka = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30)
|
||||
annual = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30)
|
||||
assert calls["n"] == 2
|
||||
assert tajaka["request_hash"]
|
||||
assert annual["request_hash"]
|
||||
|
||||
|
||||
def test_pyjhora_interpreter_lookup_is_cached(monkeypatch) -> None:
|
||||
reset_external_pyjhora_python_cache()
|
||||
calls = {"n": 0}
|
||||
|
||||
def _missing():
|
||||
calls["n"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("scripts.tajika._lookup_external_pyjhora_python", _missing)
|
||||
assert _resolve_external_pyjhora_python() is None
|
||||
assert _resolve_external_pyjhora_python() is None
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_oracle_progress_snapshot_reuses_until_the_file_changes(monkeypatch) -> None:
|
||||
reset_oracle_progress_snapshot_cache()
|
||||
calls = {"n": 0}
|
||||
real_run = __import__("subprocess").run
|
||||
|
||||
def _counting_run(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _counting_run)
|
||||
first = _oracle_progress_snapshot()
|
||||
after_first = calls["n"]
|
||||
assert after_first >= 1
|
||||
second = _oracle_progress_snapshot()
|
||||
assert calls["n"] == after_first
|
||||
assert second == first
|
||||
|
||||
oracle = ROOT / "references" / "oracle" / "dasha_shadbala_oracle_cases.json"
|
||||
stat = oracle.stat()
|
||||
later = stat.st_mtime + 5
|
||||
try:
|
||||
oracle.touch()
|
||||
import os
|
||||
os.utime(oracle, (later, later))
|
||||
third = _oracle_progress_snapshot()
|
||||
assert calls["n"] > after_first
|
||||
assert third["scope"] == first["scope"]
|
||||
finally:
|
||||
os_utime = __import__("os").utime
|
||||
os_utime(oracle, (stat.st_atime, stat.st_mtime))
|
||||
reset_oracle_progress_snapshot_cache()
|
||||
|
||||
|
||||
def test_chart_overview_gate_is_off_by_default(monkeypatch) -> None:
|
||||
monkeypatch.delenv(gate.ENV_GATE_CHART_OVERVIEW, raising=False)
|
||||
gate.reset_heavy_compute_gate(limit=1)
|
||||
held = gate.acquire_heavy_compute_slot("/api/high_rigor_workflow")
|
||||
assert held is not None
|
||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1990}) is None
|
||||
gate.release_heavy_compute_slot(held)
|
||||
assert gate.heavy_compute_status()["in_use"] == 0
|
||||
gate.reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_chart_overview_gate_only_sync_unskipped_requests(monkeypatch) -> None:
|
||||
monkeypatch.setenv(gate.ENV_GATE_CHART_OVERVIEW, "1")
|
||||
gate.reset_heavy_compute_gate(limit=1)
|
||||
body = {"year": 1990, "lat": 28.6, "lon": 77.2}
|
||||
slot = gate.acquire_heavy_compute_slot_for_request("/api/chart", body)
|
||||
assert slot is not None
|
||||
assert gate.heavy_compute_status() == {"limit": 1, "in_use": 1}
|
||||
with pytest.raises(gate.HeavyComputeBusy):
|
||||
gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1991})
|
||||
gate.release_heavy_compute_slot(slot)
|
||||
assert gate.heavy_compute_status()["in_use"] == 0
|
||||
assert gate.acquire_heavy_compute_slot_for_request(
|
||||
"/api/chart", {"skip_vedastro_main_entry_overview": True}
|
||||
) is None
|
||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"async": True}) is None
|
||||
assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"enqueue": 1}) is None
|
||||
assert "/api/chart" not in gate.HEAVY_COMPUTE_PATHS
|
||||
gate.reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_busy_log_names_holders_and_not_the_body(caplog) -> None:
|
||||
gate.reset_heavy_compute_gate(limit=1)
|
||||
held = gate.acquire_heavy_compute_slot("/api/professional_report_reference")
|
||||
assert held is not None
|
||||
secret_body = {"birth_date": "1990-01-01", "name": "must-not-appear"}
|
||||
with caplog.at_level(logging.WARNING, logger="jyotish.heavy_compute"):
|
||||
with pytest.raises(gate.HeavyComputeBusy):
|
||||
gate.acquire_heavy_compute_slot_for_request("/api/professional_report_reference", secret_body)
|
||||
text = caplog.text
|
||||
assert "/api/professional_report_reference" in text
|
||||
assert "1990-01-01" not in text
|
||||
assert "must-not-appear" not in text
|
||||
gate.release_heavy_compute_slot(held)
|
||||
assert gate.heavy_compute_status()["in_use"] == 0
|
||||
gate.reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_warm_consultation_still_skips_the_slot() -> None:
|
||||
gate.reset_heavy_compute_gate(limit=1)
|
||||
assert gate.acquire_heavy_compute_slot_for_request(
|
||||
"/api/consultation_workflow", {"consult_card_warm_only": True}
|
||||
) is None
|
||||
assert gate.heavy_compute_status()["in_use"] == 0
|
||||
gate.reset_heavy_compute_gate()
|
||||
@@ -298,8 +298,11 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
|
||||
route = SYNASTRY_ROUTE.read_text(encoding="utf-8")
|
||||
for token in (
|
||||
'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"',
|
||||
'postPython("/api/chart", selfPayload)',
|
||||
'postPython("/api/chart", partnerPayload)',
|
||||
# 原值: postPython("/api/chart", selfPayload) 与 postPython("/api/chart", partnerPayload)
|
||||
# 新值: 两次调用都展开对应 payload,并带 skip_vedastro_main_entry_overview: true
|
||||
# 原因: 合盘改为同时排本人和对方两张盘,并关掉用不上的外部概览。断言仍锁住这两次 /api/chart,同时锁住这个开关,不放宽。
|
||||
'postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })',
|
||||
'postPython("/api/chart", { ...partnerPayload, skip_vedastro_main_entry_overview: true })',
|
||||
'postPython("/api/varga_full"',
|
||||
'postPython("/api/synastry"',
|
||||
"moonLongitude(selfChart)",
|
||||
|
||||
Reference in New Issue
Block a user