Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) e21e167a73 fix(chart): lock synastry chart calls and stop overwriting the A/B note
The bounced contract still looked for the old chart calls. The research script now writes to a temp directory unless --out is set. Quota splits stay unimplemented.

Co-Authored-By: Grok (agent loop)
2026-10-15 10:19:41 +08:00
jesse-uxandGrok (agent loop) 18fdce6006 merge origin/staging into the chart subprocess gate branch
Keep the pushed fix commit and bring in the bounce brief plus the data-layer fix.

Co-Authored-By: Grok (agent loop)
2026-10-15 10:11:38 +08:00
Claude 1de3cc307b merge origin/staging into data layer types (issue #71)
Independent Staging Quality Gate / validate (push) Successful in 14m20s
Independent Staging Quality Gate / publish (push) Canceled after 15m27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 09:44:41 +08:00
Jesse_ChenandClaude Opus 5.5 2b7112a601 docs(tasks): chart page ascendant row brief (agent loop, issue #53 round 2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 09:19:35 +08:00
Jesse_ChenandClaude Opus 5.5 10d0c4bba6 docs(tasks): answer effort switch fix brief (agent loop, issue #65, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 09:03:35 +08:00
Jesse_ChenandClaude Opus 5.5 9308382ef8 docs(tasks): api chart subprocess gate fix brief (agent loop, issue #64, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 08:45:34 +08:00
jesse-uxGrok (agent loop)Cursor
336286e428 fix(chart): skip unused synastry overview and doomed report subprocesses
Synastry charts now skip the unused external overview and run together. Reports skip PyJHora subprocesses when that library is missing, and a full heavy-compute slot logs which paths hold it. Splitting quotas stays a simulation.

Gate, growth, and report pytest passed, as did the frontend route tests, tsc, lint, and the A/B script.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:02:39 +08:00
24 changed files with 1353 additions and 39 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-15 — 合盘更快(待合入 staging)
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。
+60
View File
@@ -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
@@ -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`。
+4 -1
View File
@@ -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,8 +483,10 @@
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-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` |
@@ -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:当前那段大运没有左侧那条彩色竖线,仍有浅色底能看出是「现在这一段」。
+18 -14
View File
@@ -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),
@@ -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, []);
});
+103
View File
@@ -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");
});
+49
View File
@@ -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},
+99 -10
View File
@@ -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()
+6 -10
View File
@@ -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': '深圳'}
+26 -2
View File
@@ -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
+291
View File
@@ -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()
+21
View File
@@ -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'),
+6
View File
@@ -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"
}
+197
View File
@@ -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()
+5 -2
View File
@@ -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)",