Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) dbba127e71 拆分报告渲染函数的第一阶段,把 34 个无外层依赖的辅助函数搬到 report_render。
同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。

Co-Authored-By: Grok (agent loop)
2026-10-15 20:59:11 +08:00
Jesse_ChenandClaude Opus 5.5 32b238cf86 docs(tasks): beta safety net fix brief 2 (agent loop, issue #75)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 16:48:54 +08:00
Claude 168e4dde19 merge origin/staging into beta capacity (issue #76)
Independent Staging Quality Gate / validate (push) Successful in 14m45s
Independent Staging Quality Gate / publish (push) Successful in 4m13s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 15:45:46 +08:00
Jesse_ChenandClaude Opus 5.5 2d41338897 docs(tasks): beta safety net fix brief, daily backup leaves the recovery point alone (agent loop, issue #75)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 15:44:49 +08:00
jesse-uxandGrok (agent loop) 360a667e4f fix(capacity): align two stale checks with the rate-limit edit.
The consultation contract still required await directly before runConsultationWorkflow(toolInput), and a new report test shifted three privacy allowlist lines. Keep the toolInput lock and move that test to the end. No product code change.

Co-Authored-By: Grok (agent loop)
2026-10-15 15:31:35 +08:00
jesse-uxandGrok (agent loop) 904dfe9f79 merge origin/staging into beta capacity (issue #76).
Bring in the bounce-1 fix brief without rewriting the pushed capacity commit.

Co-Authored-By: Grok (agent loop)
2026-10-15 15:24:52 +08:00
Jesse_ChenandClaude Opus 5.5 9fae294532 docs(tasks): beta capacity fix brief (agent loop, issue #76, 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 15:10:37 +08:00
jesse-uxandGrok (agent loop) a54f75e5e3 fix(capacity): limit each user and queue heavy compute briefly.
Consult, rectification turns, and report generation stop before a credit reservation when the product caps are hit. A full heavy-compute gate waits up to 8 seconds, and one consultation retries that 429 inside the same request.

Co-Authored-By: Grok (agent loop)
2026-10-15 13:33:06 +08:00
Claude ef49257b73 merge origin/staging into answer effort ab (issue #65)
Independent Staging Quality Gate / validate (push) Successful in 14m3s
Independent Staging Quality Gate / publish (push) Successful in 3m47s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 11:15:23 +08:00
Claude 7175548531 merge origin/staging into api chart subprocess gate (issue #64)
Independent Staging Quality Gate / validate (push) Successful in 13m47s
Independent Staging Quality Gate / publish (push) Successful in 4m5s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 10:44:32 +08:00
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
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
56 changed files with 3414 additions and 500 deletions
+10
View File
@@ -1,5 +1,15 @@
# 印度占星 Skill 更新日志
## 2026-10-15 — 内测容量:问得太密会停一下,人多时先排一小会儿
- 同一个人短时间问得太密、校正答得太密,或一小时里报告生成太多,会先停一下,这次不扣点。
- 同时算的人多过机器能接的两路时,先排一小会儿。排上了继续;排不上会说人有点多,并把这一轮预留的点数退回。
- Skill 版本不变(BUG-1355、BUG-1356)。
## 2026-10-15 — 合盘更快(待合入 staging)
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging)
- 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。
+1 -1
View File
@@ -37,7 +37,7 @@ The API image `WORKDIR` is `/app` (`deploy/railway-api.Dockerfile`). Runtime fil
The volume is not a backup domain. Chart cache is a TTL'd compute cache (default 900s) and is cheaper to rebuild than to archive. Async / high-rigor job state is short-lived (default TTL 3600s) and is not a restore unit. Restart persistence is the only reason the volume exists. Staging encrypted backups remain PostgreSQL-only via `deploy/backup-staging-postgres.sh`; do not add `api_scratch` to that helper. Project-prefixed volume names are `jyotisha-staging_api_scratch` and `jyotisha-production_api_scratch`.
Heavy rectification scans and high-rigor workflows share a process-wide fail-fast concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). Saturated requests return HTTP 429 with `Retry-After` and are not queued. Health checks and other light routes do not take a slot.
Heavy rectification scans and high-rigor workflows share a process-wide concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). A saturated request waits up to 8 seconds if fewer than 4 requests are already waiting, then returns HTTP 429 with `Retry-After`. Health checks and other light routes do not take a slot.
## DNS and Supabase Auth
+107
View File
@@ -17991,6 +17991,36 @@
- 复发自:无。
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
- 复发自:无。
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
- 来源:issue #64。任务书 T2。
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
- 相关记录:BUG-721、BUG-1265、BUG-1333。
- 复发自:无。
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
- 状态:investigating
@@ -36044,6 +36074,36 @@
- 复发自:无。
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
## BUG-1333 | 合盘排盘漏关外部概览,而且两张盘排队算
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
- 来源:issue #64。任务书 `docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` T1、T3、T4。
- 现象:合盘每次要排两张盘,都没关掉用不上的外部概览。每张盘多起两个后台进程,而且两张盘一张一张排。名额占满时,日志里看不出是谁占着。
- 触发条件:打开合盘。排盘接口本身默认仍走完整链,给报告和外部调用留着。
- 根因:合盘这条路由没跟上「只读盘面就关掉外部概览」的约定,也没有合同测试拦住。名额按路径一刀切,不带 skip 的排盘默认不占名额。
- 修复:合盘两次排盘都带上关闭外部概览,两张盘同时排;婚恋分支的两张分盘也同时排。前端里凡是发给排盘接口的请求体,都要带这个开关,由源码合同守着。另加一个默认关闭的开关:打开后,不带 skip 的同步排盘才占一个重计算名额。名额占满时记一行日志,只写被拒的路径和当前占用者的路径、已占秒数,不写请求正文。
- 验证:`frontend/tests/synastry-chart-skip.test.ts`、`frontend/tests/chart-request-skip-contract.test.ts`、`tests/test_api_chart_subprocess_gate.py`、`tests/test_api_heavy_compute_gate.py`。合盘耗时见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。`tests/test_supabase_user_data_contract.py` 锁住合盘路由向 `/api/chart` 请求本人和对方两张盘,且两次都带 `skip_vedastro_main_entry_overview: true`。
- 防复发:源码合同要求 `frontend/src/app/api` 和 `frontend/src/lib` 里发往 `/api/chart` 的请求体带 `skip_vedastro_main_entry_overview: true`。Python 合同按路由原文锁住这两次调用和这个开关。`/api/chart` 仍不在重计算路径名单里。
- 相关记录:BUG-161、BUG-715、BUG-717、BUG-1302。
- 复发自:无。
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。编号是任务书预留的空号,夹在 BUG-1332 和 BUG-1335 之间。
## BUG-1334 | 个人报告在没有参照引擎时仍为每次报告启动子进程
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-15
- 来源:issue #64。任务书 T2。
- 现象:每份个人报告会起大约 10 个后台进程。其中参照引擎没装时,年度回放注定失败,但仍要付启动开销。另一段静态校验和用户的盘无关,每份报告都重跑。
- 触发条件:生成个人长报告,且这台机器没有安装 PyJHora。装了参照引擎的开发机上,隔离用的子进程仍然要留着。
- 根因:子进程是为了把参照引擎隔离开,这是对的;父进程却没有先问「这台机器根本没装」。静态校验文件被挂在每份报告上,没有按文件变化复用。
- 修复:父进程先看一次能不能导入参照引擎,不能就不起子进程,返回和「子进程在没装时」相同的结果。解释器查找和静态校验各在进程内记一次;校验文件变了会重算。不缓存任何出生资料。装了参照引擎时仍走子进程。
- 验证:`tests/test_api_chart_subprocess_gate.py` 对照 `tests/fixtures/api-chart-subprocess-gate/`。生产上的收益大约是每份报告 0.5 到 0.9 秒、少掉那几个注定失败的进程,不是排查时在装了参照引擎的机器上看到的 5 秒。本机没有 PyJHora,实测见 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`。
- 防复发:无参照引擎时测试断言不会调用 `subprocess.run`,返回值与固定样本一致。
- 相关记录:BUG-721、BUG-1265、BUG-1333。
- 复发自:无。
- 修复版本:分支 `codex/api-chart-subprocess-gate-20261013`。
## BUG-1335 | 个人长报告的中文出稿和溯源哈希重复劳动
- 状态:investigating
@@ -36197,3 +36257,50 @@
- 复发自:无。
- 修复版本:未修。分支 `codex/answer-effort-ab-20261013` 只记录。原先写在 BUG-1351,该号已被数据层占用;按本文件规则保留较早的 BUG-1351,本条改为 BUG-1354,接在 BUG-1353 之后。
## BUG-1355 | 对话、校正回合和报告生成没有按用户限流
- 状态:mitigated
- 首次发现 / 最近更新:2026-10-14 / 2026-10-15
- 来源:issue #76。任务书 `docs/tasks/TASK-beta-capacity-20261014.md`。
- 现象:一个人可以连续打对话、校正回合或报告生成。报告只有每天 5 份的上限,没有按小时的上限。对话和校正没有按这个人计数。
- 触发条件:已登录后调用 `POST /api/consult`、`POST /api/rectification/agent` 或 `POST /api/reports`。开案 `POST /api/rectification/cases/open` 不计数。
- 根因:`requestRateLimits` 只覆盖地点搜索、时区、今日星语、星历、合盘和会话写入。重计算闸是全进程共享的,挡不住同一个人把模型调用打满。
- 修复:对话 10 次/分钟且 120 次/小时,校正回合 5 次/分钟且 40 次/小时,报告生成另加 3 次/小时(每天 5 份仍在)。两档都还有余量才放行;拒绝时不写入任何一档,也不预留点数。响应是 429、`code: rate_limited` 和 `Retry-After`。
- 验证:`frontend/tests/request-rate-limit.test.ts`;报告核心测试「hourly rate limit returns 429 and does not reserve」。
- 防复发:上述测试锁住桶的边界、拒绝时不记入另一档,以及三条路由在预留点数之前判断。开案路由源码不得调用这组限流。
- 相关记录:BUG-723(校正客户端已有的退避,本单不改)、BUG-1302(预热仍不占重计算名额)、BUG-1357(打回后补上的两条旧检查)。
- 复发自:无
- 修复版本:分支 `codex/beta-capacity-20261014`。
## BUG-1356 | 重计算满了立刻拒绝,对话也不再试
- 状态:mitigated
- 首次发现 / 最近更新:2026-10-14 / 2026-10-15
- 来源:issue #76。任务书 `docs/tasks/TASK-beta-capacity-20261014.md`。
- 现象:同时只有 2 个重计算名额。第 3 个请求马上收到 429。普通对话把这次 429 当成这一轮失败,不按 `Retry-After` 再试。
- 触发条件:已有 2 个请求占着 `/api/consultation_workflow` 或校正打分,又来一个对话取证。
- 根因:闸门用非阻塞信号量,满了就抛 `HeavyComputeBusy`。对话侧把 HTTP 429 记成 `workflow_rate_limited` 后直接失败。
- 修复:名额满时最多再等 8 秒,同时等待的人最多 4 个。等不到或等待的人已经满,仍抛 `HeavyComputeBusy`,`Retry-After` 仍是原来的秒数。对话对这种 429 最多再试 2 次,几次等待加起来不超过 15 秒。等待时进度行写「人有点多,排一下队」。仍失败则沿用原来的取消预留退点,只走一次,并告诉用户这一轮没排上、点数已退回。并发上限仍是 2。
- 验证:`tests/test_api_heavy_compute_gate.py` 的排队拿到名额、等待者满立刻拒绝、超时仍 429;`frontend/tests/consultation-workflow-retry.test.ts`。
- 防复发:上述测试。健康检查仍不进闸。校正客户端原有的退避(BUG-723)没有改。
- 相关记录:BUG-717、BUG-723、BUG-1302、BUG-1345、BUG-1355、BUG-1357。
- 复发自:无
- 修复版本:分支 `codex/beta-capacity-20261014`。
## BUG-1357 | 限流改完后两条旧检查没跟上
- 状态:resolved
- 首次发现:2026-10-15
- 最近更新:2026-10-15
- 影响面:`frontend/tests/consultation-voice-contract.test.ts`;`frontend/tests/personal-report-api.test.ts` 与 `tests/test_repo_privacy_markers.py` 的行号白名单。没有用户界面变化。
- 用户现象:同一个人问太密、校正太密、报告太多会先停一下,人多时会先排一小会儿。这些都对。全量检查多出 2 条失败,这一版上不了线。
- 触发条件:验收方对 `a54f75e5` 跑前端全量和 Python 全量。
- 根因:对话路由把 `runConsultationWorkflow(toolInput` 包进排队重试,源码合同仍要求 `await` 紧贴函数名。报告的小时限流测试插在文件中部,把隐私白名单按行号锁定的三行各下移 13 行。被保护的行为都还在,不是泄漏。上一轮只跑了定向测试,这两条不在那一组里。
- 修复:正则仍锁定 `runConsultationWorkflow(toolInput`,不再要求 `await` 紧贴。小时限流测试整段挪到文件末尾,断言原文不变,`SAFE_LINES` 不改。业务代码不改。
- 验证:`docs/tasks/PROGRESS-beta-capacity-fix-20261015.md`。语音合同、报告接口、镜像不引用测试共 75 通过、0 失败;隐私扫描 62 通过、0 失败;tsc 退出码 0;lint 0 错误、123 条原有警告。
- 防复发:改这三处路由的语句前,在 `frontend/tests` 和 `tests` 里 grep 被改写的原文。新测试不要插在 `SAFE_LINES` 锁定行的上面。
- 相关记录:BUG-933、BUG-1014、BUG-1355、BUG-1356
- 复发自:BUG-933(源码合同没在同一轮跟上)
- 修复版本:分支 `codex/beta-capacity-20261014`。
+21
View File
@@ -0,0 +1,21 @@
# 内测邀请码(用兑换码)
后台路径:`/admin/codes`(兑换码)。需要管理权限。不要把真实兑换码写进仓库、进度或聊天记录。完整码只在生成成功的那一次弹窗里出现,关掉就不能再看,列表里只剩掩码。
## 批量生成
1. 打开兑换码页,点「批量生成」。
2. 「每个点数」是这一批每个码到账的点数。表单初始值是 10,可以改。内测每码给多少点由当次生成的人填写,产品以后可以改这个数。
3. 「数量」一次 1 到 100 个。同一批的点数、到期时间、备注相同。
4. 到期时间可以空着。备注用来区分这一批,例如内测第几批,不要写别人的个人资料。
5. 生成后立刻把完整码交给对应的人。页面会提示关闭后无法再看。
## 查谁用了
列表有掩码、点数、状态、到期、备注、兑换账户、兑换时间、撤销时间。状态可以筛:可用、已过期、已兑换、已撤销。兑换账户和兑换时间就是谁用过。
## 作废
还没兑换、也没撤销的码,行上有「撤销」。撤销后不能再兑。已经兑换的不能从这里追回已经到账的点数。
支付在内测期间保持关闭。这页已经能批量生成、查看使用者和作废,本单不另加功能。
@@ -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,43 @@
# 进度 · 内测容量(2026-10-15)
- 分支:`codex/beta-capacity-20261014`
- 基线:开工时 `origin/staging` `ef49257b734678ff944a840076347baa02d0323c`
- 任务书:`docs/tasks/TASK-beta-capacity-20261014.md`
- 前置:`origin/codex/node-runtime-resources-20261013` 与 `origin/codex/rect-scoring-perf-20261013` 都是该 staging 的祖先(`merge-base --is-ancestor` 退出码 0)。限流文件里已有过期清理。
## 做了什么
- T1:对话 10 次/分钟 + 120 次/小时,校正回合 5 次/分钟 + 40 次/小时,报告 3 次/小时(每天 5 份不变)。拒绝发生在预留点数之前,429 + `rate_limited` + `Retry-After`。开案路由不计数。
- T2:重计算并发仍是 2。满了最多等 8 秒,同时等待最多 4 个;等不到或等待已满仍是 429,`Retry-After` 不变。对话对这种 429 最多再试 2 次,等待合计不超过 15 秒。进度行「人有点多,排一下队」,失败句「人有点多,这一轮没排上,点数已经退回。」退点仍走原来的取消预留,代理路径用已有的一次结算。
- T3:后台已能批量生成(1–100 个、每码点数自填、初始 10)、按兑换账户查看、撤销未使用的码。说明在 `docs/operations/beta-invite-codes.md`。没有扩功能。
- T4:脚本 `frontend/scripts/research/beta-load-smoke.mts`。不从 `frontend/tests` 导入。本机没有跑。
- T5:BUG-1355、BUG-1356,CHANGELOG、VOICE、DESIGN、真机两条。
## 断言
旧测试仍要求「名额被占着就拒绝」,没有改拒绝码和 `Retry-After` 的期望值。
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `tests/test_api_heavy_compute_gate.py` 立刻拒绝的夹具和单测 | `reset()` / `reset(limit=1, retry_after_seconds=3)` | 增加 `wait_seconds=0` | 默认改为最多等 8 秒。这些测试继续锁 429 和 `Retry-After`,排队另有新测试 |
| `tests/test_api_chart_subprocess_gate.py` 两处占着名额再请求 | `reset(limit=1)` | `reset(limit=1, wait_seconds=0)` | 同上。仍锁同步排盘被拒绝、日志不出现请求正文 |
## 偏离与环境缺口
- T0:仓库文档里的目标规格,staging 与生产都是 2 vCPU / 4 GB,代码默认并发 2。这台 Windows 没有 staging 的登录壳,没有采到机器上的实际核数、内存和 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY`。不能把文档规格当成已经登主机核对过。
- T4:没有 `STAGING_TEST_OTP`,没有对 staging 或生产发压测。改前、改后的并发数字都没有。服务器 CPU / 内存同样没有采样。脚本留给验收方在有测试验证码的环境重跑。20 并发档没有跑。
- 旧版对话(不走代理运行时)会重试,但进度行要等回答流开始才有,所以那条路径等待时看不到「排一下队」。现在的对话走代理运行时,进度行会换字。任务书允许把排队文案收成只多等。
- 没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
- `deploy/README.md` 有一句仍写「满了不排队」。改成了最多等 8 秒、等待不超过 4 个,避免操作说明和代码相反。没有改部署配置。
## 自测
| 检查 | 命令 | 结果数字 |
| --- | --- | --- |
| Python 闸门 | `.venv\Scripts\python.exe -m pytest tests/test_api_heavy_compute_gate.py tests/test_api_chart_subprocess_gate.py -q` | 17 通过(8 + 9),0 失败 |
| 前端相关测试 | `node node_modules/tsx/dist/cli.mjs --test tests/request-rate-limit.test.ts tests/consultation-workflow-retry.test.ts tests/personal-report-api.test.ts tests/runtime-sources-no-test-imports.test.ts` | 76 通过,0 失败 |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
| 前端 lint | `npm run lint` | 0 错误,123 条原有警告 |
| 压测 | 未执行 `frontend/scripts/research/beta-load-smoke.mts` | 本机没有 `STAGING_TEST_OTP`。没有改前/改后并发数字,没有服务器 CPU/内存 |
没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
@@ -0,0 +1,46 @@
# 进度 · 内测容量修复(2026-10-15,第 1 次打回)
- 分支:`codex/beta-capacity-20261014`
- 被验 SHA:`a54f75e5e33775d5dc91f3058f5505a56b498bf2`
- 修复单:`docs/tasks/TASK-beta-capacity-fix-20261015.md`
- 原任务书:`docs/tasks/TASK-beta-capacity-20261014.md`
- 开工时本分支最大 Bug 编号:BUG-1356。本轮新增 BUG-1357。BUG-1351、BUG-1352、BUG-1353 已在本分支文件里,没有重用。
## 做了什么
- 先把最新 `origin/staging` 合并进本分支(不改写已推送的 `a54f75e5`)。合并只带进修复单和任务索引的一行。
- 语音合同:仍锁「工作流调用的入参是 `toolInput`」,以及用户轮不塞工具 JSON。不再要求 `await` 紧贴函数名。
- 隐私白名单:采用修复单推荐的做法 a。把「hourly rate limit returns 429」整段挪到 `personal-report-api.test.ts` 末尾,断言原文不变。`SAFE_LINES` 不改。三行回到 832、860、890,且整行与白名单一致。
- 业务代码不改。
## 断言
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `consultation-voice-contract.test.ts`「consult user turn no longer dumps tool JSON」 | `/await runConsultationWorkflow\(toolInput/` | `/runConsultationWorkflow\(toolInput/` | 调用包进 `retryOnConsultationRateLimit`,`await` 不再紧贴函数名。仍锁入参是 `toolInput` |
| `personal-report-api.test.ts` 小时限流测试的位置 | 插在「429 at the daily limit」和「502 model_unavailable」之间 | 文件末尾,断言原文不变 | 插在中部让三行白名单各下移 13 行。挪走后行号复原,不改 `SAFE_LINES` |
## 源码文本再搜一遍
在 `frontend/tests` 和 `tests` 里核对三条路由被改写的语句:
- 只有语音合同把 `await runConsultationWorkflow(toolInput` 写成必须匹配。已按上表改。
- `consultation-workflow-contract.test.ts` 是在代理分支里断言**不**出现 `await runConsultationWorkflow`,与这次包装无关,没改。
- 「暂时无法生成解读」仍在非排队失败的分支里,`consult-first-frame-and-pacing-20260928.test.tsx` 继续对得上,没改。
- `toNextResponse` 多了响应头,没有测试按旧函数签名做源码断言。
- Python 侧没有按这三处被改写语句做源码文本断言。
## 偏离与环境缺口
- 没有改数据库,没跑 `test:db`。没有跑前端全量 `npm test`,没有跑 Python 快速门。
- 并发压测 `frontend/scripts/research/beta-load-smoke.mts` 未跑。没有测试验证码,修复单也写明本机和执行侧都连不上测试服务器,不要登预发。没有改前/改后并发数字,没有服务器占用采样。
## 自测
| 检查 | 命令 | 结果数字 |
| --- | --- | --- |
| 前端相关测试 | `node node_modules/tsx/dist/cli.mjs --test tests/consultation-voice-contract.test.ts tests/runtime-sources-no-test-imports.test.ts tests/personal-report-api.test.ts` | 75 通过,0 失败,0 取消 |
| 隐私扫描 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 62 通过,0 失败 |
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
| 前端 lint | `npm run lint` | 0 错误,123 条原有警告 |
| 并发压测 | 未跑 `beta-load-smoke.mts` | 没有测试验证码,没有登预发 |
@@ -0,0 +1,48 @@
# PROGRESS · 报告渲染函数拆分第一阶段
- 日期:2026-10-15
- 分支:`codex/pl9-render-split-20261013`
- 基线:`origin/staging` `32b238cf8619cfab4b1107059ec997c974eb0d13`
- 范围:只做任务书第一阶段。不改 BUG_HISTORY(开工最大号 BUG-1357,本单不占号)、CHANGELOG、前端、API 服务器。
## 做了什么
把 `render_pl9_markdown` 里不依赖外层数据的 34 个嵌套函数原样搬到 `scripts/report_render/`。函数体只把 `planet_label_cn` 改成模块常量 `PLANET_LABEL_CN`。`SIGNS` / `SIGNS_CN` 按原字面量抄到 `text.py`,避免新包顶层导入引擎。
| 模块 | 函数 |
| --- | --- |
| envelope.py | `_unwrap` `_envelope_list` `_envelope_dict` `_bool_text` `_source_dict` `_has_executed_payload` |
| text.py | `_humanize_reader_token` `_humanize_reader_group` `_humanize_tajika_candidate` `_humanize_tajika_motion` `_display_rashi_from_value` `_humanize_reader_summary_line` `_humanize_annual_highlight_line` `_humanize_annual_narrative_highlight` `_humanize_domain_title` `_workbook_readable_label` `_workbook_readable_sentence` `_workbook_reading_paragraph` `_md_cell` `_render_compact_payload` `_sign_text` `_value_text` `_compose_narrative_paragraph` `_append_unique_text` `_planet_list_text` |
| sections_misc.py | `_render_boundary_packet_summary_line` `_status_from_report_pack_section` `_section_evidence_boundary` `_domi_pl9_current_gap_appendix` `_chart_planets_from_positions` `_kp_house_focus_text` `_high_value_theme_authority_sentence` `_kp_conflict_adjudication_sentence` `_prashna_exact_cusp_boundary_sentence` |
## 排除
- `_degree_text`:函数内定义两次,本阶段不搬。
- `_sign_degree_text`、`_longitude_text`:任务书点名与度数簇一起排除。
- `_profile_aware_benchmark_appendix`:要读引擎里的 `_blocked_planned_section` 和 `ROOT_DIR`。新包不能顶层导入引擎,否则成环,也过不了干净进程导入测试。
- 依赖外层数据、或调用上述未搬函数的嵌套函数:留给下一阶段。
## 前后
| 项 | 搬之前 | 搬之后 |
| --- | --- | --- |
| `render_pl9_markdown` 行数 | 6917 | 6506 |
| 嵌套函数 | 148 | 114 |
行数少了 411,嵌套函数少了 34。没有改既有断言。
## 检查
| 检查 | 命令 | 结果数字 |
| --- | --- | --- |
| 未搬移,不屏蔽 | `report_render_split_diff.py` | 退出码 0,24 项 |
| 未搬移,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 |
| 搬移后,不屏蔽 | 同上 | 退出码 0,24 项 |
| 搬移后,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 |
| 指定 pytest + 隐私扫描 | `.venv\Scripts\python.exe -m pytest` 任务书列表与 `tests/test_repo_privacy_markers.py` | 退出码 0;236 收集,233 通过,3 跳过,0 失败 |
同一轮里,基线工作树和新代码渲染同一份 pickle,24 个 sha 全部相同。完整数据版中英 8 个 sha 在四轮里不变。屏蔽那轮曾有一次基线对基线不一致:夜间盘接口响应里年主重放一会儿是超时、一会儿是导入失败。这是屏蔽后的外部重放抖动,不是这次搬函数引入的。原样重跑后退出码 0。
## 偏离
任务书预期行数下降约 600 到 900。实际 411,因为上面三个排除项里有一份 142 行的附录。没有做第二阶段及以后。
+5 -2
View File
@@ -339,7 +339,10 @@
| `TASK-release-path-production-fix-20261014.md` | `PROGRESS-release-path-production-fix-20261014.md` | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 已修,第 2 次仍未过 | `codex/release-path-production-20261014` |
| `TASK-release-path-production-fix2-20261014.md` | `PROGRESS-release-path-production-fix2-20261014.md` | **#74 修复单 2(产品选 A)**:恢复点要带属主与权限,旧格式不得回退;演练按备份里的授权比对,并在真实 PostgreSQL 17 上跑通一次 | 待验收 | `codex/release-path-production-20261014` |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
| `TASK-beta-safety-net-fix-20261015.md` | — | **#75 产品选 A**:授权新增每日备份定时工作流,但每日备份不得改写 `recovery-baseline.env`(恢复点仍只手动创建);另记 `daily-backup-latest.env` | 待领取 | `codex/beta-safety-net-20261014` |
| `TASK-beta-safety-net-fix2-20261015.md` | — | **#75 修复单 2**:生产侧全过;飞书测试 env 文件权限 0644 在 Linux 上失败(会挡发布门禁);新测试不在 quick 门禁;磁盘 ≥70% 后备份静默停摆;监控脚本缺 sudo 与 env 文件 | 待领取 | `codex/beta-safety-net-20261014` |
| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — |
| `TASK-beta-capacity-fix-20261015.md` | — | **#76 第 1 次打回**:`a54f75e5` 限流与短排队逻辑通过;源码文本断言未跟改、插入新测试使隐私白名单行号错位 | 待领取 | `codex/beta-capacity-20261014` |
| `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) |
| `TASK-ephemeris-nutation-20261008.md` | `PROGRESS-ephemeris-nutation-20261008.md` | **恒星黄道少扣章动(BUG-1275)**:行星 = 含章动热带视位置 − 平岁差,全体偏 Δψ(≤17″),大运日期差 2–3 天;改标准算法、PyJHora 冻结对照、scoring-12、v5 重跑 | 已验收(已部署 staging `db0134f9`,BUG-1275) | `codex/ephemeris-nutation-20261008`(未推送,未部署) |
| `TASK-sync6-gate-red-20261007.md` | — | **sync6 后门禁仍红**:run 3213 快速门已过,「Frontend and database tests」开跑 20 秒后日志中断、15 分钟后判失败,后续步骤 failure 0 秒;疑运行机掉线;先 Re-run,红则本机带 Docker 跑 `CI=1 npm test` 对照 `0ed45a4b` | 已完成(Re-run 通过,BUG-1271,部署 `2df6ee72`) | — |
@@ -483,8 +486,8 @@
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | — | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待领取 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 第 1 次打回,见修复单 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待验收 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-answer-effort-ab-20261013.md` | `PROGRESS-answer-effort-ab-20261013.md` | 答题步推理强度开关 + A/B(需模型 key) | 待验收 | `codex/answer-effort-ab-20261013` |
| `TASK-answer-effort-ab-fix-20261015.md` | `PROGRESS-answer-effort-ab-fix-20261015.md` | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140),已挪到 `frontend/tests/answer-effort-probe.mts` | 待验收 | `codex/answer-effort-ab-20261013` |
| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` |
@@ -0,0 +1,37 @@
# TASK · 内测容量修复单(issue #76,第 1 次打回)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-beta-capacity-20261014.md`
- 被验 SHA:`a54f75e5e33775d5dc91f3058f5505a56b498bf2`(`codex/beta-capacity-20261014`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
## 1. 验收结论:逻辑通过,全量多出 2 条失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 按用户限流 | 通过 | 问、校正每轮、报告每小时各有分钟 / 小时两档;任一档满就整体拒,拒绝不记账;都在扣点之前 |
| 重计算短排队 | 通过 | 并发仍是 2(BUG-717);最多 4 个等待、每个最多 8 秒,等不到仍 429(BUG-720 有上限) |
| 前端排队重试 | 通过 | 最多 2 次、总等待 ≤ 15 秒;最终失败走原取消路径 |
| tsc / lint | 通过 | — |
| **前端全量** | **未通过:新增 1 条** | `frontend/tests/consultation-voice-contract.test.ts`「consult user turn no longer dumps tool JSON that the model would parrot」:`/await runConsultationWorkflow\(toolInput/` 不再匹配——`route.ts` 改成了 `await retryOnConsultationRateLimit({ run: () => runConsultationWorkflow(toolInput, …) })` |
| **Python 全量** | **未通过:新增 1 条** | `tests/test_repo_privacy_markers.py::test_tracked_repository_has_no_privacy_markers`:`HOME_PREFIX frontend/tests/personal-report-api.test.ts lines=845,873,903` |
第 2 条不是真的隐私泄漏:这三行是已有的、经审过的虚构路径样本,`tests/test_repo_privacy_markers.py` 的 `SAFE_LINES` 按「文件 + 行号 + 整行」锁定它们。本单在它们上面插入了一条 13 行的新测试,行号整体下移,白名单就对不上了。
## 2. 修法要求
1. `consultation-voice-contract.test.ts`:按 §7.3 写三栏改断言,**意图不放宽**——仍要锁住「工作流调用的入参是 `toolInput`」与该测试原本关心的那段(用户轮不再塞工具 JSON);新正则匹配 `runConsultationWorkflow(toolInput` 即可,不得去掉对 `toolInput` 的锁定。
2. 隐私白名单二选一(推荐 a):
a. 把新加的「hourly rate limit returns 429」测试挪到文件末尾,让那三行回到原行号,`SAFE_LINES` 不动;
b. 更新 `SAFE_LINES` 里这三行的行号,按三栏写原值 / 新值 / 原因。
3. 再 grep 一遍 `route.ts` / `rectification/agent/route.ts` / `reports/route.ts` 被改写语句在 `frontend/tests`、`tests` 里的源码文本断言(§7.8)。
4. 交回前本机跑:`npx tsx --test tests/consultation-voice-contract.test.ts tests/runtime-sources-no-test-imports.test.ts` 与 `python -m pytest tests/test_repo_privacy_markers.py -q`。
5. 业务代码不改。
## 3. 环境缺口(不阻断)
`frontend/scripts/research/beta-load-smoke.mts` 并发压测未跑(无测试验证码、无预发主机)。合入部署后由 Claude 在验收清单里列给产品负责人,或另开一单在 staging 跑。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,31 @@
# TASK · 内测安全网修复单(issue #75,产品选 A)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-beta-safety-net-20261014.md`
- 被验 SHA:`53d8df257ddef8918034acef36df0b4b7daade2c`(`codex/beta-safety-net-20261014`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- **决策记录**:产品负责人 2026-10-15 在 issue #75 选 **A**——**授权新增定时工作流 `.gitea/workflows/backup-production-daily.yml`**(每天北京时间 04:00 自动连生产做加密备份),但**每日备份不得改动「当前恢复点」记录**。恢复点仍只由产品负责人手动点「Create production recovery」创建。仍不改已有四个生产工作流的语义;仍不点任何生产按钮。
## 1. 问题
`deploy/run-production-recovery.sh` 无论由谁调用,结尾都会无条件写 `$DEPLOY_PATH/.state/recovery-baseline.env`(`RECOVERY_REFERENCE`、`RECOVERY_SHA256`、`RECOVERY_FORMAT=2` 等)。发布、迁移、回退都读这份文件当「当前恢复点」。每日备份复用这个脚本,于是每天 04:00 都会把它换成当天的自动备份——产品负责人上线前手动建的恢复点,隔一夜就被替换。
## 2. 修法要求
1. **每日备份不写 `recovery-baseline.env`**:`RECOVERY_BASENAME_PREFIX=production-daily` 时(或新增一个明确的模式参数,二选一,写进 PROGRESS),脚本仍完整执行「导出 → 加密 → 还原核对(含 #74 的表权限、列权限、属主三项比对)→ 写 `*-restore-verify.json`」,但**跳过写 `recovery-baseline.env`**,并在输出里打印 `baseline_updated=false`。手动恢复点路径行为不变,打印 `baseline_updated=true`。
2. 每日备份的核对结果单独记录:在 `$DEPLOY_PATH/.state/` 下另写一个 `daily-backup-latest.env`(只含文件名、sha256、校验时间、格式版本,不含数据、不含密钥),供告警 / 运维查看;发布、迁移、回退工作流**不得**读取它。
3. 合同测试(`tests/test_beta_safety_net.py` 或 `frontend/tests/staging-backend-workflows.test.ts`):
- 每日模式下 `recovery-baseline.env` 不被创建或修改(用临时目录 + 假 docker 跑脚本,断言文件 mtime / 内容不变);
- 手动模式下仍会写,且含 `RECOVERY_FORMAT=2`;
- 三个读取恢复点的生产工作流 / 脚本不引用 `daily-backup-latest.env`。
4. runbook 与 `deploy/README.md`:写清「每日备份只做存档与核对,不是发布用的恢复点;上线前仍要手动点 Create production recovery」。
5. 清理脚本只删 `production-daily-*` 前缀的文件(原任务书已要求)——再确认不会删 `production-pre-migration-*` 手动恢复点。
## 3. 验收
- Claude 会对本单做完整验收(上一轮在发现定时工作流后就停下等拍板,其余部分尚未逐行验):逐行看新工作流与各脚本、跑 `tests/test_beta_safety_net.py`、Python 全量、前端全量、`docker compose config`。
- 交回前本机跑:`python -m pytest tests/test_beta_safety_net.py tests/test_repo_privacy_markers.py -q`、`npx tsx --test tests/runtime-sources-no-test-imports.test.ts tests/staging-backend-workflows.test.ts`。
## 4. 让步顺序
无。
@@ -0,0 +1,45 @@
# TASK · 内测安全网修复单 2(issue #75)
- 日期:2026-10-15
- 前序:`TASK-beta-safety-net-20261014.md`、`TASK-beta-safety-net-fix-20261015.md`(产品选 A)
- 被验 SHA:`5c0d895391b5d0806c98de669a2e9cdcd61e3335`(`codex/beta-safety-net-20261014`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- 红线不变:仍不改已有四个生产工作流与 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;不点生产按钮
## 1. 验收结论:面向生产的部分全部过;一条阻断 + 三条要一起修
已确认成立:每日工作流只从 main 跑、与恢复点工作流同一把锁 / 同样的密钥处理、不回显密钥;每日模式**任何路径都不写** `recovery-baseline.env`(只有手动分支写),失败时什么状态都不写;`daily-backup-latest.env` 只有 4 个键;发布 / 迁移 / 回退不读它;手动恢复点(含 `RECOVERY_FORMAT=2` 与权限比对)不变;清理脚本只认 `^production-daily-\d{8}T\d{6}Z\.dump\.enc$` 的普通文件,碰不到 `production-pre-migration-*`;错误分类只外发校验过的 errorCode / 路由 / UUID,不发用户内容;compose 只加了 6 个服务一致的 `logging`。
### B1(阻断)· 测试在 Linux 上失败
`tests/test_beta_safety_net.py` 约 616–634 行 `test_feishu_payload_keeps_only_allowed_fields`:约 76–77 行用 `write_text` 写 `monitor.env`,默认权限 0644;`deploy/monitor/notify-feishu.sh` 约 18–23 行在 Linux 上拒绝非 0600 的环境文件 → 退出 1。macOS / Windows 上跳过了这项检查所以没发现。`release-quality-gate.yml` 在 Linux 上跑全树 pytest,提升 main 会被它挡住。
- 修:测试里写完后 `os.chmod(env_file, 0o600)`。脚本里的检查是对的,不改。
### N1 · 新测试不进 staging 门禁
`scripts/run_quality_gate.py` 的 quick 清单(约 55–90 行)没有 `tests/test_beta_safety_net.py`,合同只在发布时才跑——B1 就是这样漏过去的。把它加进 quick 清单。
### N2 · 磁盘 ≥70% 后备份静默停摆
`run-production-recovery.sh` 约 149–153 行磁盘 ≥70% 拒绝;`backup-production-daily.yml` 约 145–146 行只在备份成功后才清理旧文件 → 一旦到 70%,旧文件永不清理、每天都失败;磁盘告警 80% 才响,也没人检查 `daily-backup-latest.env` 有多旧。
- 修:清理放在备份**之前**(或 `if: always()`);`healthcheck-alert.sh` 增加「最近一次每日备份超过 36 小时」告警(读 `daily-backup-latest.env` 的 `verified_at`)。加测试。
### N3 · 两个监控脚本在生产主机上大概率跑不起来
`deploy/monitor/error-digest.sh` 约 54–59 行、`deploy/find-request-log.sh` 约 300–307 行直接调 `docker`,其它生产脚本与恢复演练都用 `sudo -n docker`;也没导出 `DATABASE_ENV_FILE`,`docker-compose.postgres.yml` 会回落到不存在的 `../.env.staging.database`,Compose 拒绝加载。错误汇总由 cron 每 5 分钟跑,会每次失败、什么都不发。
- 修:统一 `sudo -n docker`,显式传入生产的 compose 文件与 env 文件路径(与 `run-production-deploy.sh` 同一写法);测试断言命令行形状。
## 2. 顺手修(不阻断,一并做)
- `notify-feishu.sh` 约 96–101 行:`curl` 加 `--fail`,并检查飞书返回体里的 `code == 0`;失败时不记成「已发送」。
- `error-digest.sh`:同一类告警 30 分钟内不重复发。
- `find-request-log.sh`:真实路径按时间戳排序(去掉 compose 前缀后再排),与 README 说法一致。
## 3. 验收标准
- Linux:`python3 -m pytest tests/test_beta_safety_net.py -q` 全绿;Claude 全量 pytest、前端全量无新增;`bash -n` 全过;quick 门禁包含新测试。
- 交回前本机跑上面那条 pytest(Windows 上请用 WSL 或在测试里显式断言 0600 已生效)。
## 4. 让步顺序
B1、N1、N3 不可让;N2 的「过旧告警」可写理由后让;第 2 节可让。
@@ -0,0 +1,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,越过了现在的上限。
+6
View File
@@ -0,0 +1,6 @@
# 内测容量 · 真机两条(2026-10-14)
用测试账号。不要把出生资料、姓名、邮箱写进记录。
1. 快速连发触发限流。登录后在一分钟里连续发送超过 10 次普通对话。预期:出现「这一会儿问得有点密,先停一下。」这一轮不扣点。等提示里的时间过去再发一次,可以正常问。
2. 排队提示出现与消失。需要两个人同时占着计算(或用压测脚本打满两个名额)时,第三个人发普通对话。预期:进度行先出现「人有点多,排一下队」,排上之后这句消失,回到正在计算。若一直排不上,出现「人有点多,这一轮没排上,点数已经退回。」余额与发送前一致。
+1 -1
View File
@@ -907,7 +907,7 @@ or user IDs.
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
**普通对话发出即有反馈(2026-09-28,TASK-consult-first-frame-and-pacing,BUG-1074)。** 按下发送的那一帧,回答行就带着步骤栏,唯一一行是排队行「收到,正在看你的问题…」(`QUEUED_TIMELINE_ROW`,行内 `InlineSpinner` + shimmer,summary 写「正在分析」)。服务端流的第一个字节也是这句(`activity` 事件,phase `received`),所以那一帧到达时行不换字。不再有「分类结果回来之前整行静默」:只有服务端判定为寒暄的回复(`run.completed` 带 `responseKind: "smalltalk"`)才把步骤栏收掉、只留一句话,寒暄前会闪约一秒这一行,属于流式生成中的正常形态。首事件之后的行照旧由服务端事件推动;不新增第二种 live 标记,不加 spinner 面板或骨架。
**普通对话发出即有反馈(2026-09-28,TASK-consult-first-frame-and-pacing,BUG-1074)。** 按下发送的那一帧,回答行就带着步骤栏,唯一一行是排队行「收到,正在看你的问题…」(`QUEUED_TIMELINE_ROW`,行内 `InlineSpinner` + shimmer,summary 写「正在分析」)。服务端流的第一个字节也是这句(`activity` 事件,phase `received`),所以那一帧到达时行不换字。不再有「分类结果回来之前整行静默」:只有服务端判定为寒暄的回复(`run.completed` 带 `responseKind: "smalltalk"`)才把步骤栏收掉、只留一句话,寒暄前会闪约一秒这一行,属于流式生成中的正常形态。首事件之后的行照旧由服务端事件推动;不新增第二种 live 标记,不加 spinner 面板或骨架。重计算名额满了时,计算那一行先换成「人有点多,排一下队」(`activity` phase `compute-queue`,仍是原来的 live 行),排上之后回到「正在计算本命盘…」。排不上则整轮失败,文案是「人有点多,这一轮没排上,点数已经退回。」
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后若还有前事核对,服务端写一条自己的采用消息(「已按 HH:MM 排盘…」),核对题的四个选项挂在这条消息下,进行中那一行由它替换,不挂到上一题的回复下(BUG-1146)。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
+4
View File
@@ -480,3 +480,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 不写健康诊断、投资建议、法律意见;不写「一定」「必然」「注定」。
- 出生时间没确认时不用宫位,只按月亮所在星座写,caution 固定为「出生时间尚未确认,这里只看大方向,不依赖具体分钟。」
## 人多与问得太密(2026-10-15)
不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」
@@ -0,0 +1,277 @@
/**
* Staging-only smoke for 10 or 20 concurrent consultations.
*
* Not imported by frontend/src. Reads STAGING_TEST_OTP from the environment
* and never prints it. Refuses a production host. Does not sample server
* CPU or memory; that needs a staging shell this process does not have.
*
* node node_modules/tsx/dist/cli.mjs scripts/research/beta-load-smoke.mts --users 10
*/
const usersArg = process.argv.find((arg) => arg.startsWith("--users="));
const usersFlag = process.argv.indexOf("--users");
const requestedUsers = Number(usersArg ? usersArg.slice("--users=".length) : process.argv[usersFlag + 1]);
const baseUrl = (process.env.STAGING_BASE_URL || "https://staging.jyotisha.chat").replace(/\/$/, "");
const otp = process.env.STAGING_TEST_OTP || "";
const emailDomain = process.env.STAGING_TEST_EMAIL_DOMAIN || "example.test";
function refuse(message: string): never {
console.error(message);
process.exit(2);
}
let host = "";
try {
host = new URL(baseUrl).host;
} catch {
refuse("STAGING_BASE_URL is not a URL");
}
if (host === "jyotisha.chat" || host === "www.jyotisha.chat" || host === "admin.jyotisha.chat") {
refuse("refusing production host");
}
if (!/^\d{6}$/.test(otp)) refuse("STAGING_TEST_OTP is not set");
if (requestedUsers !== 10 && requestedUsers !== 20) refuse("pass --users 10 or --users 20");
if (!/^[a-z0-9.-]+$/.test(emailDomain)) refuse("STAGING_TEST_EMAIL_DOMAIN is not a plain domain");
type Sample = {
kind: "consult" | "rectification";
status: number;
firstByteMs: number | null;
totalMs: number;
failed: boolean;
http429: boolean;
http5xx: boolean;
};
const samples: Sample[] = [];
const CONSULT_QUEUE_FAILED = "人有点多,这一轮没排上,点数已经退回。";
function percentile(values: number[], ratio: number): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
return sorted[index] ?? null;
}
async function request(jar: Map<string, string>, path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (jar.size > 0) headers.set("cookie", [...jar].map(([key, value]) => `${key}=${value}`).join("; "));
const response = await fetch(`${baseUrl}${path}`, { ...init, headers, redirect: "manual" });
const setCookies = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
for (const line of setCookies) {
const pair = line.split(";")[0] ?? "";
const eq = pair.indexOf("=");
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
return response;
}
async function timed(jar: Map<string, string>, path: string, body: unknown, kind: Sample["kind"]): Promise<void> {
const started = Date.now();
let status = 0;
let firstByteMs: number | null = null;
let failed = false;
try {
const response = await request(jar, path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
status = response.status;
const reader = response.body?.getReader();
if (reader) {
let text = "";
for (;;) {
const chunk = await reader.read();
if (firstByteMs === null && (chunk.value || chunk.done)) firstByteMs = Date.now() - started;
if (chunk.value) text += new TextDecoder().decode(chunk.value);
if (chunk.done) break;
}
failed = text.includes('"type":"run.failed"') || text.includes(CONSULT_QUEUE_FAILED);
} else {
firstByteMs = Date.now() - started;
failed = status >= 400;
}
} catch {
status = 0;
failed = true;
}
samples.push({
kind,
status,
firstByteMs,
totalMs: Date.now() - started,
failed,
http429: status === 429,
http5xx: status >= 500 || status === 0,
});
}
async function login(index: number): Promise<Map<string, string> | null> {
const jar = new Map<string, string>();
const email = `capacity-smoke-${index}@${emailDomain}`;
const sent = await request(jar, "/api/auth/email-otp/send-verification-otp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, type: "sign-in" }),
});
if (!sent.ok) return null;
const signed = await request(jar, "/api/auth/sign-in/email-otp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, otp }),
});
if (!signed.ok || jar.size === 0) return null;
return jar;
}
async function prepareAccount(jar: Map<string, string>): Promise<boolean> {
const saved = await request(jar, "/api/account", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "容量冒烟",
birth_date: "1990-06-15",
reported_birth_time: "12:00",
birth_time_source: "family_exact",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
country_code: "CN",
province_code: "110000",
city_code: "110000",
district_code: "110101",
latitude: 39.9042,
longitude: 116.4074,
timezone_offset: 8,
}),
});
return saved.ok;
}
async function oneUser(index: number): Promise<string[]> {
const deleted: string[] = [];
const jar = await login(index);
if (!jar) {
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
return deleted;
}
if (!await prepareAccount(jar)) {
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
return deleted;
}
const models = await request(jar, "/api/models");
const catalog = models.ok ? await models.json().catch(() => null) as { defaultModelId?: string } | null : null;
const modelId = catalog?.defaultModelId;
if (!modelId) {
samples.push({ kind: "consult", status: 0, firstByteMs: null, totalMs: 0, failed: true, http429: false, http5xx: true });
return deleted;
}
const sessionId = crypto.randomUUID();
const created = await request(jar, "/api/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: sessionId,
title: "容量冒烟",
theme: "general",
model_id: modelId,
messages: [],
session_type: "consultation",
rectification_case_id: null,
chart_profile_role: "self",
}),
});
if (!created.ok) {
samples.push({ kind: "consult", status: created.status, firstByteMs: null, totalMs: 0, failed: true, http429: created.status === 429, http5xx: created.status >= 500 });
return deleted;
}
const chart = {
year: 1990,
month: 6,
day: 15,
hour: 12,
minute: 0,
lat: 39.9042,
lon: 116.4074,
tz: 8,
city: "北京",
ayanamsa: "lahiri",
};
await timed(jar, "/api/consult", {
...chart,
requestId: crypto.randomUUID(),
sessionId,
modelId,
consultationMode: "verified_chart",
theme: "general",
question: "今年工作整体顺不顺?",
history: [],
}, "consult");
await timed(jar, "/api/consult", {
...chart,
requestId: crypto.randomUUID(),
sessionId,
modelId,
consultationMode: "verified_chart",
theme: "general",
question: "那接下来几个月呢?",
history: [],
}, "consult");
if (index < 3) {
const opened = await request(jar, "/api/rectification/cases/open", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ intent: "homepage", requestId: crypto.randomUUID() }),
});
const openedBody = opened.ok ? await opened.json().catch(() => null) as { caseId?: string; sessionId?: string } | null : null;
if (openedBody?.caseId && openedBody.sessionId) {
for (const message of ["我小时候搬家过一次。", "那次大概是上小学之前。"]) {
await timed(jar, "/api/rectification/agent", {
caseId: openedBody.caseId,
sessionId: openedBody.sessionId,
requestId: crypto.randomUUID(),
action: "message",
message,
}, "rectification");
}
deleted.push(openedBody.sessionId);
} else {
samples.push({
kind: "rectification",
status: opened.status,
firstByteMs: null,
totalMs: 0,
failed: true,
http429: opened.status === 429,
http5xx: opened.status >= 500 || opened.status === 0,
});
}
}
const removed = await request(jar, `/api/sessions/${sessionId}`, { method: "DELETE" });
if (removed.ok) deleted.push(sessionId);
return deleted;
}
const startedAt = Date.now();
const cleaned = (await Promise.all(Array.from({ length: requestedUsers }, (_, index) => oneUser(index)))).flat();
const consults = samples.filter((sample) => sample.kind === "consult");
const finalFailures = consults.filter((sample) => sample.failed || sample.http429 || sample.http5xx);
const firstBytes = consults.map((sample) => sample.firstByteMs).filter((value): value is number => value !== null);
const totals = consults.map((sample) => sample.totalMs);
const summary = {
users: requestedUsers,
host,
requests: samples.length,
consults: consults.length,
http429: samples.filter((sample) => sample.http429).length,
http5xx: samples.filter((sample) => sample.http5xx).length,
finalFailures: finalFailures.length,
finalFailureRatio: consults.length === 0 ? null : Number((finalFailures.length / consults.length).toFixed(4)),
firstByteP95Ms: percentile(firstBytes, 0.95),
totalP95Ms: percentile(totals, 0.95),
sessionsDeleted: cleaned.length,
serverSample: "not taken; this process has no staging docker stats",
elapsedMs: Date.now() - startedAt,
};
console.log(JSON.stringify(summary));
+24 -8
View File
@@ -129,6 +129,12 @@ import {
import { persistGuardedSessionTitle } from "@/lib/session-title-guard";
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
import { z } from "zod";
import { CONSULTATION_QUEUE_FAILED_MESSAGE, isConsultationRateLimitFailure, retryOnConsultationRateLimit } from "@/lib/consultation-workflow-retry";
import {
consumeUserRateLimitSet,
userRateLimitDeniedResponse,
userRateLimitMessages,
} from "@/lib/request-rate-limit";
export const runtime = "nodejs";
export const maxDuration = 480;
@@ -367,6 +373,11 @@ export async function POST(request: Request) {
const currentDate = chinaCalendarDate(requestTime);
const userId = user.id;
const limited = consumeUserRateLimitSet(["consultMinute", "consultHour"], userId);
if (!limited.ok) {
const denied = userRateLimitDeniedResponse(limited.retryAfterSeconds, userRateLimitMessages.consult);
return NextResponse.json(denied.body, { status: denied.status, headers: denied.headers });
}
const requestId = parsed.data.requestId;
const sessionId = parsed.data.sessionId;
const consultationTheme = parsed.data.theme;
@@ -1786,9 +1797,11 @@ export async function POST(request: Request) {
theme: parsed.data.theme,
});
const workflowContext = applyBirthTimeModeToWorkflowContext(
await runConsultationWorkflow(toolInput, {
foreground: true,
plan: prepared.preReserveResult,
await retryOnConsultationRateLimit({
run: () => runConsultationWorkflow(toolInput, {
foreground: true,
plan: prepared.preReserveResult,
}),
}),
consultationMode,
{ birthTimeSource: prepared.serverChart.truth.birthTimeSource },
@@ -1894,12 +1907,15 @@ export async function POST(request: Request) {
console.error(
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
);
const rateLimited = isConsultationRateLimitFailure(error);
return NextResponse.json(
{
error: "暂时无法生成解读",
message: "咨询服务暂时不可用,请稍后再试。",
recovery: "稍后重试,或换一个模型继续。",
},
rateLimited
? { error: CONSULTATION_QUEUE_FAILED_MESSAGE, message: CONSULTATION_QUEUE_FAILED_MESSAGE }
: {
error: "暂时无法生成解读",
message: "咨询服务暂时不可用,请稍后再试。",
recovery: "稍后重试,或换一个模型继续。",
},
{ status: 503 },
);
}
@@ -18,6 +18,11 @@ import {
replyToDeclaredBirthWindow,
} from "@/lib/rectification-agentic/v9/agent-route-typed-message";
import { respondWithRectificationAgentTurn } from "@/lib/rectification-agentic/v9/agent-route-agent-turn";
import {
consumeUserRateLimitSet,
userRateLimitDeniedResponse,
userRateLimitMessages,
} from "@/lib/request-rate-limit";
export const runtime = "nodejs";
export const maxDuration = 240;
@@ -66,6 +71,20 @@ export async function POST(request: Request) {
const frozen = await refuseWhenAccountDeletionPending(userId);
if (frozen) return frozen;
// Opening a case is POST /api/rectification/cases/open and is not counted.
// Every turn on this route is, and the check is before any credit reserve.
const limited = consumeUserRateLimitSet(
["rectificationTurnMinute", "rectificationTurnHour"],
userId,
);
if (!limited.ok) {
const denied = userRateLimitDeniedResponse(
limited.retryAfterSeconds,
userRateLimitMessages.rectificationTurn,
);
return NextResponse.json(denied.body, { status: denied.status, headers: denied.headers });
}
if (action === "opening") {
const consistency = await finalizeSuccessfulTurnExit({
accounting: accounting as never, userId, caseId, action, segmentOpening: true,
+10 -2
View File
@@ -5,6 +5,7 @@ import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-sna
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
import { stampReportBirthFingerprint } from "@/lib/report-birth-binding";
import { runConsultationWorkflow } from "@/mastra";
import { consumeUserRateLimitSet, userRateLimitMessages } from "@/lib/request-rate-limit";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import {
@@ -68,8 +69,8 @@ function sanitizedErrorCode(error: unknown): string {
return "UnknownError";
}
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
return NextResponse.json(response.body, { status: response.status });
function toNextResponse(response: { status: number; body: Record<string, unknown>; headers?: Readonly<Record<string, string>> }) {
return NextResponse.json(response.body, { status: response.status, headers: response.headers });
}
function listReportView(
@@ -308,6 +309,13 @@ export async function POST(request: Request) {
},
persistence,
model: defaultModel,
consumeReportRateLimit: userId
? () => {
const decision = consumeUserRateLimitSet(["reportGenerateHour"], userId);
if (decision.ok) return decision;
return { ...decision, message: userRateLimitMessages.reportGenerate };
}
: undefined,
billing: {
reserve: async ({ userId: billingUserId, requestId, modelId }) => {
try {
+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),
@@ -61,6 +61,7 @@ export const ChatMessageRow = memo(function ChatMessageRow({
received: "working",
"loading-method": "searching",
"chart-calculation": "solving",
"compute-queue": "working",
"evidence-validation": "working",
"answer-composition": "composing",
} as const)[message.activity.phase]
@@ -4,6 +4,8 @@
* turn is classified or the chart is read (TASK-consult-first-frame-and-pacing-20260928 D1).
*/
export const CONSULTATION_RECEIVED_LABEL = "收到,正在看你的问题…";
export const CONSULTATION_QUEUE_LABEL = "人有点多,排一下队";
export const CONSULTATION_QUEUE_FAILED_MESSAGE = "人有点多,这一轮没排上,点数已经退回。";
export const CONSULTATION_LOADING_METHOD_LABEL = "正在读取印度占星分析规则…";
export const CONSULTATION_CHART_CALCULATION_LABEL = "正在计算本命盘…";
export const CONSULTATION_EVIDENCE_VALIDATION_LABEL = "正在核对可用证据…";
@@ -10,6 +10,9 @@ export const publicActivityPhaseSchema = z.enum([
"received",
"loading-method",
"chart-calculation",
// Heavy compute is full. The calculate row keeps this sentence while the
// consultation retries, then returns to the chart-calculation label.
"compute-queue",
"evidence-validation",
"answer-composition",
]);
@@ -70,7 +70,7 @@ export function reduceConsultationTimeline(
return applyCalculateHints(created);
}
if (event.type === "activity") {
if (event.phase === "chart-calculation") {
if (event.phase === "chart-calculation" || event.phase === "compute-queue") {
return patchRow(state, CALCULATE_ID, {
status: "live",
label: event.label,
@@ -0,0 +1,57 @@
import { ConsultationWorkflowError } from "../mastra/consultation-workflow.ts";
import {
CONSULTATION_QUEUE_FAILED_MESSAGE,
CONSULTATION_QUEUE_LABEL,
} from "./consultation-activity-labels.ts";
export { CONSULTATION_QUEUE_FAILED_MESSAGE, CONSULTATION_QUEUE_LABEL };
export const CONSULTATION_QUEUE_MAX_RETRIES = 2;
export const CONSULTATION_QUEUE_MAX_WAIT_MS = 15_000;
export function isConsultationRateLimitFailure(error: unknown): boolean {
if (error instanceof ConsultationWorkflowError) return error.code === "workflow_rate_limited";
if (error instanceof Error && error.cause !== undefined) return isConsultationRateLimitFailure(error.cause);
return false;
}
function retryDelaySeconds(error: unknown): number | undefined {
if (error instanceof ConsultationWorkflowError && error.code === "workflow_rate_limited") {
return error.retryAfterSeconds ?? 2;
}
if (error instanceof Error && error.cause !== undefined) return retryDelaySeconds(error.cause);
return undefined;
}
export function consultationRunFailureMessage(error: unknown, fallback: string): string {
return isConsultationRateLimitFailure(error) ? CONSULTATION_QUEUE_FAILED_MESSAGE : fallback;
}
/**
* Retry one consultation workflow call when the heavy-compute gate answers
* 429. At most two retries, and the sleeps together stay within 15 seconds.
*/
export async function retryOnConsultationRateLimit<T>(input: Readonly<{
run: () => Promise<T>;
onQueue?: () => void | Promise<void>;
sleep?: (ms: number) => Promise<void>;
}>): Promise<T> {
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
let retries = 0;
let waitedMs = 0;
for (;;) {
try {
return await input.run();
} catch (error) {
const retryAfterSeconds = retryDelaySeconds(error);
if (retryAfterSeconds === undefined || retries >= CONSULTATION_QUEUE_MAX_RETRIES) throw error;
const remainingMs = CONSULTATION_QUEUE_MAX_WAIT_MS - waitedMs;
if (remainingMs <= 0) throw error;
const waitMs = Math.min(retryAfterSeconds * 1000, remainingMs);
retries += 1;
waitedMs += waitMs;
await input.onQueue?.();
await sleep(waitMs);
}
}
}
+23 -1
View File
@@ -63,7 +63,11 @@ export const personalReportCreateRequestSchema = z.object({
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
export type ReportRouteResponse = Readonly<{ status: number; body: Record<string, unknown> }>;
export type ReportRouteResponse = Readonly<{
status: number;
body: Record<string, unknown>;
headers?: Readonly<Record<string, string>>;
}>;
export type ReportServicePort = Pick<
PersonalReportService,
@@ -231,6 +235,10 @@ export type ReportCreateCoreDeps = Readonly<{
persistence: ReportServicePort;
model: Readonly<{ id: string; configVersion?: number; inputCostMicrousdPerMillion?: number; outputCostMicrousdPerMillion?: number }> | null;
billing?: ReportBillingPort;
/** Hourly per-user cap. Checked after a replay and the daily cap, before reserve. */
consumeReportRateLimit?: () =>
| { readonly ok: true }
| { readonly ok: false; readonly retryAfterSeconds: number; readonly message: string };
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
@@ -387,6 +395,20 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
let billingReserved = false;
if (deps.billing && selectedModel) {
if (deps.consumeReportRateLimit) {
const limited = deps.consumeReportRateLimit();
if (!limited.ok) {
return {
status: 429,
headers: { "Retry-After": String(limited.retryAfterSeconds) },
body: {
error: limited.message,
code: "rate_limited",
retryAfterSeconds: limited.retryAfterSeconds,
},
};
}
}
let authorization;
try {
authorization = await deps.billing.reserve({ userId, requestId: payload.requestId, modelId: selectedModel.id });
+69 -6
View File
@@ -23,6 +23,18 @@ export const requestRateLimits = {
ephemeris: { limit: 30, windowMs: 60_000 },
synastry: { limit: 8, windowMs: 60 * 60_000 },
sessionWrite: { limit: 40, windowMs: 60_000 },
// Two windows each: a minute burst and an hour budget (product 2026-10-14).
consultMinute: { limit: 10, windowMs: 60_000 },
consultHour: { limit: 120, windowMs: 60 * 60_000 },
rectificationTurnMinute: { limit: 5, windowMs: 60_000 },
rectificationTurnHour: { limit: 40, windowMs: 60 * 60_000 },
reportGenerateHour: { limit: 3, windowMs: 60 * 60_000 },
} as const;
export const userRateLimitMessages = {
consult: "这一会儿问得有点密,先停一下。",
rectificationTurn: "这一会儿校正问得有点密,先停一下。",
reportGenerate: "这一小时的报告已经够多了,先停一下。",
} as const;
export type RequestRateLimitBucket = keyof typeof requestRateLimits;
@@ -54,30 +66,41 @@ function sweepExpiredRateLimitKeys(
}
}
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
consumeCalls += 1;
function evaluateRequestRateLimit(
input: RequestRateLimitInput,
record: boolean,
): RequestRateLimitDecision {
const now = input.now ?? Date.now();
const store = input.store ?? defaultStore();
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
if (record) {
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
consumeCalls += 1;
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
}
}
const windowStart = now - input.windowMs;
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
if (recent.length >= input.limit) {
const retryAfterSeconds = Math.max(1, Math.ceil((recent[0]! + input.windowMs - now) / 1000));
store.set(input.key, recent);
if (record) store.set(input.key, recent);
return { ok: false, retryAfterSeconds };
}
if (!record) return { ok: true };
recent.push(now);
store.set(input.key, recent);
return { ok: true };
}
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
return evaluateRequestRateLimit(input, true);
}
export function consumeUserRequestRateLimit(
bucket: RequestRateLimitBucket,
userId: string,
now?: number,
store?: RequestRateLimitStore,
): RequestRateLimitDecision {
const spec = requestRateLimits[bucket];
return consumeRequestRateLimit({
@@ -85,5 +108,45 @@ export function consumeUserRequestRateLimit(
limit: spec.limit,
windowMs: spec.windowMs,
now,
store,
});
}
/**
* Allow the request only when every bucket still has room. A refusal writes
* nothing, so the bucket that still had room is not charged for the miss.
*/
export function consumeUserRateLimitSet(
buckets: readonly RequestRateLimitBucket[],
userId: string,
now?: number,
store?: RequestRateLimitStore,
): RequestRateLimitDecision {
let retryAfterSeconds = 0;
for (const bucket of buckets) {
const spec = requestRateLimits[bucket];
const decision = evaluateRequestRateLimit({
key: `${bucket}:${userId}`,
limit: spec.limit,
windowMs: spec.windowMs,
now,
store,
}, false);
if (!decision.ok) retryAfterSeconds = Math.max(retryAfterSeconds, decision.retryAfterSeconds);
}
if (retryAfterSeconds > 0) return { ok: false, retryAfterSeconds };
for (const bucket of buckets) consumeUserRequestRateLimit(bucket, userId, now, store);
return { ok: true };
}
export function userRateLimitDeniedResponse(retryAfterSeconds: number, message: string) {
return {
status: 429 as const,
headers: { "Retry-After": String(retryAfterSeconds) },
body: {
error: message,
code: "rate_limited" as const,
retryAfterSeconds,
},
};
}
+2 -1
View File
@@ -22,6 +22,7 @@ import {
} from "./consultation-step-timing.ts";
import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
import { consultationRunFailureMessage } from "./consultation-workflow-retry.ts";
import { logTruncatedReasoning } from "./consultation-budget.ts";
import {
classifyPass4,
@@ -1177,7 +1178,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
send(controller, {
type: "run.failed",
code,
message: runFailedMessage(code),
message: consultationRunFailureMessage(error, runFailedMessage(code)),
...(failureReceipt ? { receipt: failureReceipt } : {}),
});
if (!disconnected) controller.close();
+42 -15
View File
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS, type ProviderReasoningEffort } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import { CONSULTATION_QUEUE_LABEL, retryOnConsultationRateLimit } from "../lib/consultation-workflow-retry.ts";
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
import {
buildEvidenceCard,
@@ -1309,6 +1310,36 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
const runQueuedWorkflow = async (
toolInput: ReturnType<typeof consultationInputSchema.parse>,
domainPlan: ConsultationPlan,
progressLabel: string,
) => {
let queued = false;
const workflow = await retryOnConsultationRateLimit({
run: () => (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
}),
onQueue: async () => {
queued = true;
await context.writer?.custom({
type: "data-jyotish-activity",
data: { phase: "compute-queue", label: CONSULTATION_QUEUE_LABEL },
});
},
});
if (queued) {
await context.writer?.custom({
type: "data-jyotish-activity",
data: { phase: "chart-calculation", label: progressLabel },
});
}
return workflow;
};
if (consultDomainConcurrency() === 1) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
@@ -1344,13 +1375,11 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const workflow = await runQueuedWorkflow(
toolInput,
domainPlan,
chartCalculationProgressLabel(index + 1, domains.length),
);
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
@@ -1374,7 +1403,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
},
});
}
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
const parallelExecutions = await Promise.all(domains.map(async (domain, domainIndex) => {
if (!domain) return null;
const domainPlan = ctx.plan
&& domains.length === 1
@@ -1396,13 +1425,11 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const workflow = await runQueuedWorkflow(
toolInput,
domainPlan,
chartCalculationProgressLabel(domainIndex + 1, domains.length),
);
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
+11 -1
View File
@@ -102,14 +102,23 @@ export type ConsultationWorkflowFailureCode = typeof consultationWorkflowFailure
export class ConsultationWorkflowError extends Error {
readonly code: ConsultationWorkflowFailureCode;
readonly retryAfterSeconds?: number;
constructor(code: ConsultationWorkflowFailureCode, message: string) {
constructor(code: ConsultationWorkflowFailureCode, message: string, retryAfterSeconds?: number) {
super(message);
this.name = "ConsultationWorkflowError";
this.code = code;
if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds;
}
}
function parseRetryAfterHeader(raw: string | null): number {
if (!raw) return 2;
const seconds = Number(raw.trim());
if (!Number.isFinite(seconds)) return 2;
return Math.max(1, Math.ceil(seconds));
}
function httpFailureCode(status: number): ConsultationWorkflowFailureCode {
if (status === 400) return "workflow_bad_request";
if (status === 403) return "workflow_forbidden";
@@ -174,6 +183,7 @@ export async function runConsultationWorkflow(
throw new ConsultationWorkflowError(
response.ok ? "workflow_empty_response" : httpFailureCode(response.status),
data?.error || data?.message || `Jyotish API returned ${response.status}`,
response.status === 429 ? parseRetryAfterHeader(response.headers.get("retry-after")) : undefined,
);
}
const parsed = consultationWorkflowResponseSchema.safeParse(data);
@@ -0,0 +1,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, []);
});
@@ -179,7 +179,10 @@ test("consult user turn no longer dumps tool JSON that the model would parrot",
assert.doesNotMatch(chartBranch, /JSON\.stringify\(toolInput\)/);
assert.doesNotMatch(chartBranch, /经过服务端校验的工具参数/);
assert.match(route, /文末技法审计表/);
assert.match(chartBranch, /await runConsultationWorkflow\(toolInput/);
// 原值: /await runConsultationWorkflow\(toolInput/
// 新值: /runConsultationWorkflow\(toolInput/
// 原因: 调用包进 retryOnConsultationRateLimit,await 不再紧贴函数名。仍锁工作流入参是 toolInput;用户轮不塞工具 JSON 的断言不动。
assert.match(chartBranch, /runConsultationWorkflow\(toolInput/);
});
// 原值: test "the opener is a shape — 反差, 谁推谁修, 扮演哪个象, then at most three actions"(锁 OPENER SHAPE 四步、≤ 20 字行动、降级路线「第 3 步照给」)
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { ConsultationWorkflowError } from "../src/mastra/consultation-workflow.ts";
import { CONSULTATION_QUEUE_LABEL } from "../src/lib/consultation-activity-labels.ts";
import {
CONSULTATION_QUEUE_FAILED_MESSAGE,
consultationRunFailureMessage,
isConsultationRateLimitFailure,
retryOnConsultationRateLimit,
} from "../src/lib/consultation-workflow-retry.ts";
import {
emptyConsultationTimeline,
reduceConsultationTimelineEvents,
} from "../src/lib/consultation-run-timeline.ts";
test("a 429 is retried once and the queue line is shown before success", async () => {
let calls = 0;
const queued: string[] = [];
const sleeps: number[] = [];
const result = await retryOnConsultationRateLimit({
run: async () => {
calls += 1;
if (calls === 1) throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
return "ok";
},
onQueue: () => { queued.push(CONSULTATION_QUEUE_LABEL); },
sleep: async (ms) => { sleeps.push(ms); },
});
assert.equal(result, "ok");
assert.equal(calls, 2);
assert.deepEqual(sleeps, [2000]);
assert.deepEqual(queued, ["人有点多,排一下队"]);
});
test("three 429s stop within two retries and 15 seconds", async () => {
let calls = 0;
let slept = 0;
await assert.rejects(
() => retryOnConsultationRateLimit({
run: async () => {
calls += 1;
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
},
sleep: async (ms) => { slept += ms; },
}),
(error: unknown) => {
assert.equal(isConsultationRateLimitFailure(error), true);
return true;
},
);
assert.equal(calls, 3);
assert.equal(slept, 4_000);
assert.ok(slept <= 15_000);
assert.equal(
consultationRunFailureMessage(new ConsultationWorkflowError("workflow_rate_limited", "busy", 2), "其他失败"),
CONSULTATION_QUEUE_FAILED_MESSAGE,
);
assert.equal(consultationRunFailureMessage(new Error("other"), "其他失败"), "其他失败");
});
test("Retry-After cannot push the total wait past 15 seconds", async () => {
let calls = 0;
let slept = 0;
await assert.rejects(() => retryOnConsultationRateLimit({
run: async () => {
calls += 1;
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 20);
},
sleep: async (ms) => { slept += ms; },
}));
assert.equal(calls, 2);
assert.equal(slept, 15_000);
});
test("the queue sentence replaces the calculate row and the chart label comes back", () => {
const calculating = reduceConsultationTimelineEvents([
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算个人星盘" },
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
], emptyConsultationTimeline());
const queued = reduceConsultationTimelineEvents([
{ type: "activity", phase: "compute-queue", label: CONSULTATION_QUEUE_LABEL },
], calculating);
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.label, "人有点多,排一下队");
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.status, "live");
const resumed = reduceConsultationTimelineEvents([
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
], queued);
assert.equal(resumed.rows.find((row) => row.kind === "calculate")?.label, "正在计算本命盘…");
});
test("a failed consultation still settles through the existing cancel path before the queue sentence", () => {
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const onErrorAt = stream.indexOf("await options.onError?.");
const messageAt = stream.indexOf("consultationRunFailureMessage(error, runFailedMessage(code))");
assert.ok(onErrorAt > 0 && messageAt > onErrorAt);
assert.match(consult, /onError: \(error\) => settleRun\(\s*cancel/);
assert.match(consult, /else await cancel\(\)/);
assert.match(consult, /retryOnConsultationRateLimit/);
assert.match(consult, /CONSULTATION_QUEUE_FAILED_MESSAGE/);
});
@@ -1525,3 +1525,19 @@ test("core read: the detail response has no coverSubject, and never the fingerpr
assert.doesNotMatch(body, new RegExp(BIRTH_FP));
assert.doesNotMatch(body, /coverSubject|birthFingerprint|birth_date|latitude|longitude|timezone|birthPlaceLabel|北京/);
});
// 原值: 插在「429 at the daily limit」和「502 model_unavailable」之间。
// 新值: 整段挪到文件末尾,断言不变。
// 原因: 插在中部让 privacy 白名单按行号锁定的三行各下移 13 行。挪到末尾后那三行回到原行号,SAFE_LINES 不改。
test("core create: hourly rate limit returns 429 and does not reserve", async () => {
const billing = billingHarness();
const response = await resolveReportCreate(baseDeps({
billing: billing.billing,
consumeReportRateLimit: () => ({ ok: false, retryAfterSeconds: 12, message: "这一小时的报告已经够多了,先停一下。" }),
}));
assert.equal(response.status, 429);
assert.equal(response.body.code, "rate_limited");
assert.equal(response.body.retryAfterSeconds, 12);
assert.equal(response.headers?.["Retry-After"], "12");
assert.equal(billing.reservations, 0);
});
+100
View File
@@ -1,8 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import {
consumeRequestRateLimit,
consumeUserRateLimitSet,
requestRateLimits,
resetRequestRateLimitMaintenanceForTests,
userRateLimitDeniedResponse,
userRateLimitMessages,
} from "../src/lib/request-rate-limit.ts";
test("a user is allowed up to the window limit and then must wait", () => {
@@ -110,3 +115,98 @@ test("keys past the longest seen window are swept, and a one-hour key survives 6
assert.equal(mixed.has("ephemeris:person"), true);
resetRequestRateLimitMaintenanceForTests();
});
test("consult allows 10 per minute and refuses the 11th without charging the hour bucket further", () => {
const store = new Map<string, number[]>();
for (let index = 0; index < 10; index += 1) {
const decision = consumeUserRateLimitSet(
["consultMinute", "consultHour"],
"person",
1_000 + index,
store,
);
assert.deepEqual(decision, { ok: true });
}
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 2_000, store);
assert.equal(denied.ok, false);
if (!denied.ok) assert.equal(denied.retryAfterSeconds, 59);
assert.equal(store.get("consultMinute:person")?.length, 10);
assert.equal(store.get("consultHour:person")?.length, 10);
assert.equal(requestRateLimits.consultMinute.limit, 10);
assert.equal(requestRateLimits.consultHour.limit, 120);
});
test("the hour bucket can refuse while the minute bucket still has room, and neither bucket records the miss", () => {
const store = new Map<string, number[]>();
for (let index = 0; index < 120; index += 1) {
consumeRequestRateLimit({
key: "consultHour:person",
limit: 120,
windowMs: 60 * 60_000,
now: index * 1_000,
store,
});
}
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 130_000, store);
assert.equal(denied.ok, false);
assert.equal(store.has("consultMinute:person"), false);
assert.equal(store.get("consultHour:person")?.length, 120);
});
test("rectification turns allow 5 per minute and 40 per hour, reports allow 3 per hour", () => {
const store = new Map<string, number[]>();
for (let index = 0; index < 5; index += 1) {
assert.deepEqual(
consumeUserRateLimitSet(["rectificationTurnMinute", "rectificationTurnHour"], "person", 5_000 + index, store),
{ ok: true },
);
}
const denied = consumeUserRateLimitSet(
["rectificationTurnMinute", "rectificationTurnHour"],
"person",
5_100,
store,
);
assert.equal(denied.ok, false);
assert.equal(store.get("rectificationTurnMinute:person")?.length, 5);
assert.equal(requestRateLimits.rectificationTurnMinute.limit, 5);
assert.equal(requestRateLimits.rectificationTurnHour.limit, 40);
assert.equal(requestRateLimits.reportGenerateHour.limit, 3);
assert.equal(requestRateLimits.reportGenerateHour.windowMs, 60 * 60_000);
const reports = new Map<string, number[]>();
for (let index = 0; index < 3; index += 1) {
assert.deepEqual(
consumeUserRateLimitSet(["reportGenerateHour"], "person", 8_000 + index, reports),
{ ok: true },
);
}
const reportDenied = consumeUserRateLimitSet(["reportGenerateHour"], "person", 9_000, reports);
assert.equal(reportDenied.ok, false);
assert.equal(reports.get("reportGenerateHour:person")?.length, 3);
});
test("a denied response carries 429, rate_limited, and Retry-After", () => {
const denied = userRateLimitDeniedResponse(12, userRateLimitMessages.consult);
assert.equal(denied.status, 429);
assert.equal(denied.headers["Retry-After"], "12");
assert.deepEqual(denied.body, {
error: "这一会儿问得有点密,先停一下。",
code: "rate_limited",
retryAfterSeconds: 12,
});
});
test("consult, rectification turns, and report creation check the limit before they reserve", () => {
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const rectification = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const caseOpen = readFileSync(new URL("../src/app/api/rectification/cases/open/route.ts", import.meta.url), "utf8");
const reports = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
const reportCore = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8");
assert.ok(consult.indexOf("consumeUserRateLimitSet(") < consult.indexOf("reserve_consultation_usage"));
assert.match(consult, /userRateLimitDeniedResponse\(/);
assert.ok(rectification.indexOf("consumeUserRateLimitSet(") < rectification.indexOf("respondWithRectificationAgentTurn("));
assert.equal(caseOpen.includes("consumeUserRateLimitSet"), false);
assert.match(reports, /consumeUserRateLimitSet\(\["reportGenerateHour"\]/);
assert.ok(reportCore.indexOf("if (deps.consumeReportRateLimit)") < reportCore.indexOf("deps.billing.reserve"));
});
+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},
+147 -18
View File
@@ -1,20 +1,28 @@
"""Fail-fast bounded concurrency for heavy Jyotish API compute endpoints.
"""Bounded concurrency for heavy Jyotish API compute endpoints.
Request threads that run rectification scans or high-rigor workflows share one
process-wide semaphore sized for the 2 vCPU production host. Saturated requests
return immediately; they are not queued. Health checks and other light routes
must not call this gate.
Request threads that run rectification scans or consultation workflows share one
process-wide semaphore sized for the 2 vCPU production host. A saturated request
waits up to 8 seconds when fewer than 4 threads are already waiting. If that
queue is full or the wait expires, the caller still gets HTTP 429 and
Retry-After. A thread is never parked without a deadline. Health checks and
other light routes must not call this gate.
"""
from __future__ import annotations
import logging
import os
import threading
import time
DEFAULT_CONCURRENCY = 2
DEFAULT_RETRY_AFTER_SECONDS = 2
DEFAULT_QUEUE_WAIT_SECONDS = 8.0
DEFAULT_MAX_WAITERS = 4
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
_LOG = logging.getLogger("jyotish.heavy_compute")
HEAVY_COMPUTE_PATHS = frozenset(
{
@@ -46,10 +54,33 @@ HEAVY_COMPUTE_PATHS = frozenset(
_state_lock = threading.Lock()
_semaphore: threading.BoundedSemaphore | None = None
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
_limit = DEFAULT_CONCURRENCY
_wait_seconds = DEFAULT_QUEUE_WAIT_SECONDS
_max_waiters = DEFAULT_MAX_WAITERS
_waiters = 0
_holders: list[dict] = []
_holder_seq = 0
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):
"""No heavy-compute slot is free; callers must fail fast with HTTP 429."""
"""The short queue could not take this request; callers return HTTP 429."""
error_code = "ERR_COMPUTE_BUSY"
@@ -78,9 +109,11 @@ def reset_heavy_compute_gate(
*,
limit: int | None = None,
retry_after_seconds: int | None = None,
wait_seconds: float | None = None,
max_waiters: int | None = None,
) -> None:
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
global _semaphore, _retry_after_seconds
global _semaphore, _retry_after_seconds, _limit, _holders, _wait_seconds, _max_waiters, _waiters
resolved_limit = (
limit
if limit is not None
@@ -91,33 +124,129 @@ def reset_heavy_compute_gate(
if retry_after_seconds is not None
else _parse_positive_int(os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS)
)
resolved_wait = DEFAULT_QUEUE_WAIT_SECONDS if wait_seconds is None else max(0.0, float(wait_seconds))
resolved_waiters = DEFAULT_MAX_WAITERS if max_waiters is None else max(0, int(max_waiters))
with _state_lock:
_retry_after_seconds = resolved_retry
_limit = resolved_limit
_wait_seconds = resolved_wait
_max_waiters = resolved_waiters
_waiters = 0
_holders = []
_semaphore = threading.BoundedSemaphore(resolved_limit)
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:
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
if not is_heavy_compute_path(path):
return 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 _remember_locked(path: str, semaphore: threading.BoundedSemaphore) -> _HeldSlot:
"""Record a permit that this thread already holds. Caller owns ``_state_lock``."""
global _holder_seq
_holder_seq += 1
holder_id = _holder_seq
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
return _HeldSlot(semaphore, holder_id)
def _reject_busy(path: str, retry_after: int) -> None:
with _state_lock:
snapshot = _holder_view(time.monotonic())
_LOG.warning(
"heavy_compute_busy rejected_path=%s holders=%s",
path,
snapshot,
)
raise HeavyComputeBusy(retry_after)
def _acquire_recorded(path: str) -> _HeldSlot:
"""Take one permit. Wait briefly when full; still raise once the queue cannot."""
global _waiters
with _state_lock:
semaphore, retry_after = _ensure_locked()
if not semaphore.acquire(blocking=False):
raise HeavyComputeBusy(retry_after)
return semaphore
if semaphore.acquire(blocking=False):
return _remember_locked(path, semaphore)
if _waiters >= _max_waiters:
queued = False
else:
_waiters += 1
queued = True
wait_seconds = _wait_seconds
if not queued:
_reject_busy(path, retry_after)
acquired = False
try:
acquired = bool(semaphore.acquire(blocking=True, timeout=wait_seconds))
finally:
with _state_lock:
_waiters -= 1
if not acquired:
_reject_busy(path, retry_after)
with _state_lock:
return _remember_locked(path, semaphore)
def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None:
def heavy_compute_waiter_count() -> int:
"""Threads parked inside the short queue. Tests use this to avoid races."""
with _state_lock:
return _waiters
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
"""Acquire a slot for a gated path. Light paths return None."""
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:
_ensure_locked()
return {"limit": _limit, "in_use": len(_holders)}
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,
)
@@ -3357,14 +3357,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
try:
self._enforce_request_security(require_json=True)
body = self._read_json_body()
# Chart-save warm builds today's packet on the same route. It must
# not take a heavy-compute slot: the cap is 2, and a question asked
# right after save would otherwise come back 429.
warm_only = (
path == '/api/consultation_workflow'
and body.get('consult_card_warm_only') is True
)
compute_slot = None if warm_only else acquire_heavy_compute_slot(path)
# Warm chart-save must not take a slot (BUG-1302). Chart overview
# gating is off unless JYOTISH_GATE_CHART_OVERVIEW=1. Both live in
# the gate module so this file only keeps the call.
compute_slot = acquire_heavy_compute_slot_for_request(path, body)
if path == '/api/location/resolve':
city = str(body.get('city') or '').strip()
city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'}
+115 -414
View File
@@ -109,6 +109,94 @@ if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
try:
from scripts.report_render.envelope import (
_bool_text,
_envelope_dict,
_envelope_list,
_has_executed_payload,
_source_dict,
_unwrap,
)
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.envelope import (
_bool_text,
_envelope_dict,
_envelope_list,
_has_executed_payload,
_source_dict,
_unwrap,
)
try:
from scripts.report_render.sections_misc import (
_chart_planets_from_positions,
_domi_pl9_current_gap_appendix,
_high_value_theme_authority_sentence,
_kp_conflict_adjudication_sentence,
_kp_house_focus_text,
_prashna_exact_cusp_boundary_sentence,
_render_boundary_packet_summary_line,
_section_evidence_boundary,
_status_from_report_pack_section,
)
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.sections_misc import (
_chart_planets_from_positions,
_domi_pl9_current_gap_appendix,
_high_value_theme_authority_sentence,
_kp_conflict_adjudication_sentence,
_kp_house_focus_text,
_prashna_exact_cusp_boundary_sentence,
_render_boundary_packet_summary_line,
_section_evidence_boundary,
_status_from_report_pack_section,
)
try:
from scripts.report_render.text import (
PLANET_LABEL_CN,
_append_unique_text,
_compose_narrative_paragraph,
_display_rashi_from_value,
_humanize_annual_highlight_line,
_humanize_annual_narrative_highlight,
_humanize_domain_title,
_humanize_reader_group,
_humanize_reader_summary_line,
_humanize_reader_token,
_humanize_tajika_candidate,
_humanize_tajika_motion,
_md_cell,
_planet_list_text,
_render_compact_payload,
_sign_text,
_value_text,
_workbook_readable_label,
_workbook_readable_sentence,
_workbook_reading_paragraph,
)
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.text import (
PLANET_LABEL_CN,
_append_unique_text,
_compose_narrative_paragraph,
_display_rashi_from_value,
_humanize_annual_highlight_line,
_humanize_annual_narrative_highlight,
_humanize_domain_title,
_humanize_reader_group,
_humanize_reader_summary_line,
_humanize_reader_token,
_humanize_tajika_candidate,
_humanize_tajika_motion,
_md_cell,
_planet_list_text,
_render_compact_payload,
_sign_text,
_value_text,
_workbook_readable_label,
_workbook_readable_sentence,
_workbook_reading_paragraph,
)
try:
from calculation_profile_contract import (
@@ -3501,173 +3589,7 @@ def render_pl9_markdown(packet: dict) -> str:
else {}
)
def _unwrap(raw_value):
# 兼容 packet 字段的 response_envelope 升级:
# 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。
if (
isinstance(raw_value, dict)
and 'result' in raw_value
and 'response_envelope' in raw_value
):
return raw_value.get('result')
return raw_value
def _envelope_list(raw_value):
raw_value = _unwrap(raw_value)
if isinstance(raw_value, list):
return raw_value
if isinstance(raw_value, tuple):
return list(raw_value)
# 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。
if isinstance(raw_value, dict) and 'result' in raw_value:
nested = raw_value.get('result')
if isinstance(nested, list):
return nested
if isinstance(nested, tuple):
return list(nested)
return []
def _envelope_dict(raw_value):
raw_value = _unwrap(raw_value)
return raw_value if isinstance(raw_value, dict) else {}
def _bool_text(value):
return '是' if value else '否'
planet_label_cn = {
'Sun': '太阳',
'Moon': '月亮',
'Mars': '火星',
'Mercury': '水星',
'Jupiter': '木星',
'Venus': '金星',
'Saturn': '土星',
'Rahu': '北交点',
'Ketu': '南交点',
}
def _humanize_reader_token(token: str) -> str:
value = str(token or '').strip()
if not value:
return value
if value in SIGNS_CN:
return SIGNS_CN[value]
if value in planet_label_cn:
return planet_label_cn[value]
return value
def _humanize_reader_group(raw_group: str) -> str:
seen = set()
parts = []
for item in str(raw_group or '').split(','):
humanized = _humanize_reader_token(item)
if humanized and humanized not in seen:
seen.add(humanized)
parts.append(humanized)
return ' / '.join(parts)
def _humanize_tajika_candidate(name: str) -> str:
return {
'Ithasala_candidate': '趋近相位候选',
'Easarapha_candidate': '分离相位候选',
}.get(str(name or ''), '年度相位候选')
def _humanize_tajika_motion(motion: str) -> str:
return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-'))
def _display_rashi_from_value(raw_value):
if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS):
return _humanize_reader_token(SIGNS[raw_value])
return _humanize_reader_token(raw_value)
def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str:
summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {}
bits = [_md_cell(packet.get('packet_mode'))]
for key, suffix in summary_pairs:
value = summary.get(key)
if value not in (None, '', []):
bits.append(f"{_md_cell(value)}{suffix}")
return f"参考包摘要:{';'.join(bits)}。"
def _humanize_reader_summary_line(summary_line: str) -> str:
summary_line = _unwrap(summary_line)
if isinstance(summary_line, dict):
summary_line = (
summary_line.get('brief')
or summary_line.get('text')
or summary_line.get('summary')
or ''
)
text = str(summary_line or '').strip()
if not text:
return text
if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。":
return "这一年的年度主线已经形成。"
if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.":
return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。"
if text == "Source: dasha_master_pack.interpretation_triggers.":
return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。"
if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.":
return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。"
if text == "Tajika Yogas: annual candidate structures visible":
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
if text == "Sahams: annual sensitive points visible":
return "Sahams 当前已经能读到年度敏感点。"
match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text)
if match:
topic = match.group(1).strip()
left = _humanize_reader_group(match.group(2))
right = _humanize_reader_group(match.group(3))
topic_label = {
'Muntha': 'Muntha',
'Year Lord': 'Year Lord',
'Annual Asc': '返照上升',
}.get(topic, topic)
if left == right:
return f"{topic_label} 当前结果指向{left}。"
return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。"
simple_match = re.match(r'^([^:]+):\s*(.+)$', text)
if simple_match:
topic = simple_match.group(1).strip()
value = _humanize_reader_group(simple_match.group(2))
if topic == 'Year Lord' and value:
return f"Year Lord 当前优先候选指向{value}。"
if topic == 'Muntha' and value:
return f"Muntha 当前优先候选指向{value}。"
if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower():
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower():
return "Sahams 当前已经能读到年度敏感点。"
return text
def _humanize_annual_highlight_line(highlight: str) -> str:
text = str(highlight or '').strip()
if not text:
return text
if text == "Solar Return / Tajika annual shell is available.":
return "本年度返照与 Tajika 时间结构已经生成。"
if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.":
return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。"
if text == "The report should read annual structure first, then the evidence appendix.":
return "这一层适合从年度结构展开,再回头核对证据附录。"
return text
def _humanize_annual_narrative_highlight(highlight: str) -> str:
return _humanize_annual_highlight_line(str(highlight)).rstrip('。')
def _humanize_domain_title(title: str) -> str:
text = str(title or '').strip()
if not text:
return text
mapping = {
"Career and public direction": "事业与公众方向",
"Wealth and growth opportunities": "财富与增长机会",
"Relationship and alliance themes": "关系与合作主题",
"Health and vitality caution": "健康与精力提醒",
"Risk, delay, and friction markers": "阻力、延迟与摩擦提示",
"Timing windows and cross-check needs": "时间窗口与交叉核对重点",
}
return mapping.get(text, text)
planet_label_cn = PLANET_LABEL_CN
def _degree_text(value):
if value in (None, '', [], {}):
@@ -3677,12 +3599,6 @@ def render_pl9_markdown(packet: dict) -> str:
except (TypeError, ValueError):
return str(value)
def _source_dict(*candidates):
for candidate in candidates:
if isinstance(candidate, dict) and candidate:
return candidate
return {}
def _render_basic_tables() -> list[str]:
birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {}
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
@@ -3907,113 +3823,11 @@ def render_pl9_markdown(packet: dict) -> str:
readable = [_segment_sentence(segment_id).rstrip('。') for segment_id in segment_ids]
return f"{group_name}包括:{';'.join(readable)}。"
def _workbook_readable_label(worksheet_id: str) -> str:
if worksheet_id == 'birth_and_parameters':
return '出生与计算口径'
if worksheet_id == 'd1_rasi_bhava':
return '本命盘主结构'
if worksheet_id == 'strengths_and_scores':
return '力量与技术表'
if worksheet_id == 'divisional_and_special_charts':
return '分盘与特殊点位'
if worksheet_id == 'timing_and_predictive_systems':
return '时间系统与流年层'
if worksheet_id == 'advanced_systems':
return '进阶体系'
if worksheet_id == 'ai_and_audit':
return '审计与证据附录'
return '补充工作簿'
def _workbook_readable_sentence(worksheet_id: str) -> str:
if worksheet_id == 'birth_and_parameters':
return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。'
if worksheet_id == 'd1_rasi_bhava':
return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。'
if worksheet_id == 'strengths_and_scores':
return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。'
if worksheet_id == 'divisional_and_special_charts':
return '分盘与特殊点位可作为扩展阅读,适合补充看细节。'
if worksheet_id == 'timing_and_predictive_systems':
return '大运、行运与年度层已经可以形成时间主线。'
if worksheet_id == 'advanced_systems':
return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。'
if worksheet_id == 'ai_and_audit':
return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。'
return f'{worksheet_id} 已纳入当前报告。'
def _reader_group_paragraph(group_name: str, segment_ids: list[str]) -> str:
if not segment_ids:
return f'{group_name}当前暂无稳定内容。'
return _segment_group_sentence(group_name, segment_ids)
def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str:
parts = []
for worksheet_id in worksheet_ids:
sentence = _workbook_readable_sentence(worksheet_id).rstrip('。')
parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}")
if not parts:
return '当前暂无可用工作簿阅读顺序。'
return ';'.join(parts) + '。'
def _md_cell(value) -> str:
text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip()
return text.replace('|', '/')
def _render_compact_payload(value, depth: int = 0) -> str:
if value in (None, '', [], {}):
return '-'
if depth >= 2:
return _md_cell(type(value).__name__)
if isinstance(value, dict):
parts = []
for key in sorted(value, key=str):
if key in {
'narrative_items',
'domain_paragraphs',
'response_envelope',
'input_hash',
'profile_hash',
'raw_calculation',
'shared_full_report_authority',
}:
continue
parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}")
return '; '.join(parts) if parts else '-'
if isinstance(value, (list, tuple)):
items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]]
suffix = ' …' if len(value) > 8 else ''
return ', '.join(items) + suffix if items else '-'
return _md_cell(value)
def _status_from_report_pack_section(section: dict) -> str:
if not isinstance(section, dict):
return 'blocked'
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
if section.get('status'):
return str(section.get('status'))
if audit.get('status'):
return str(audit.get('status'))
if section:
return 'partial_verified'
return 'blocked'
def _section_evidence_boundary(section: dict) -> str:
if not isinstance(section, dict):
return 'section_missing'
exports = section.get('exports') if isinstance(section.get('exports'), dict) else {}
ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {}
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
boundaries = []
if ai_bundle.get('contains_private_pl9_text') is False:
boundaries.append('no_private_pl9_text')
if ai_bundle.get('claim_policy'):
boundaries.append(str(ai_bundle.get('claim_policy')))
if audit.get('must_not_claim'):
boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3])))
if section.get('reason'):
boundaries.append(str(section.get('reason')))
return '; '.join(boundaries) if boundaries else 'evidence_labeled'
def _full_report_pack_assembly_section() -> list[str]:
if not isinstance(full_report_pack, dict) or not full_report_pack:
return []
@@ -4071,9 +3885,6 @@ def render_pl9_markdown(packet: dict) -> str:
out.append('')
return out
def _domi_pl9_current_gap_appendix() -> list[str]:
return []
def _profile_aware_benchmark_appendix() -> list[str]:
dashboard_path = Path(ROOT_DIR) / 'references/oracle/profile_aware_benchmark_boundary_dashboard_2026_08_27.json'
if not dashboard_path.exists():
@@ -4401,21 +4212,6 @@ def render_pl9_markdown(packet: dict) -> str:
out.append('')
return out
def _chart_planets_from_positions(positions: dict) -> dict:
chart_planets = {}
if not isinstance(positions, dict):
return chart_planets
for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
planet = positions.get(planet_name)
if not isinstance(planet, dict):
continue
sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name')
if not sign:
continue
degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0)))
chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0}
return chart_planets
def _render_south_chart(
positions: dict,
ascendant_row: dict,
@@ -5385,30 +5181,6 @@ def render_pl9_markdown(packet: dict) -> str:
return "UL 在待确认位置、Pranapada 在待确认位置,但关系主题仍建议把制度承诺层与气质投射层分开看。"
return None
def _kp_house_focus_text(value) -> str:
house_label_map = {
2: '收入与资源',
5: '机会与投入',
6: '职责与压力',
7: '合作与伴侣',
9: '运气与扩张',
10: '事业与位置',
11: '回报与支持',
}
parts = []
if isinstance(value, (list, tuple)):
raw_parts = [str(item).strip() for item in value if str(item).strip()]
else:
raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()]
for part in raw_parts:
try:
house_no = int(part)
except ValueError:
parts.append(part)
continue
parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]")
return '、'.join(parts) if parts else '-'
def _graha_padas_domain_bridge(domain: str) -> str | None:
graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {}
graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {}
@@ -6230,41 +6002,6 @@ def render_pl9_markdown(packet: dict) -> str:
segments.append('这些层当前仍只作为 ' + ' / '.join(statuses) + ' 的辅助阅读,不直接升级为主题主结论')
return '主题支持补充:' + ';'.join(segments) + '。'
def _high_value_theme_authority_sentence(domain: str) -> str | None:
domain_label_map = {
'career': '事业',
'wealth': '财务',
'relationship': '关系',
}
action_map = {
'career': '优先排序事业推进顺序、外部舞台和月份节奏',
'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序',
'relationship': '优先排序合作推进、联结温度和关系节奏',
}
return (
"主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与"
+ f"{domain_label_map.get(domain, domain)}章主结论,"
+ f"用来{action_map.get(domain, '补强主题排序')};"
+ "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。"
)
def _kp_conflict_adjudication_sentence(domain: str) -> str:
domain_label_map = {
'career': '事业',
'wealth': '财务',
'relationship': '关系',
}
focus_map = {
'career': '外部舞台、职位变化和项目推进',
'wealth': '资源承接、现金流和回收节奏',
'relationship': '合作推进、联结温度和承诺节奏',
}
return (
f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。"
f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。"
f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。"
)
def _patyayini_reader_bridge() -> list[str]:
if not replay_patyayini_rows:
return []
@@ -6305,12 +6042,6 @@ def render_pl9_markdown(packet: dict) -> str:
)
return out
def _prashna_exact_cusp_boundary_sentence() -> str | None:
return (
"如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;"
"Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。"
)
def _reader_unexported_high_value_layers_section() -> list[str]:
divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {}
points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {}
@@ -6394,12 +6125,6 @@ def render_pl9_markdown(packet: dict) -> str:
except (TypeError, ValueError):
return _md_cell(value)
def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str:
if not isinstance(row, dict):
return '-'
sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key)
return _md_cell(sign)
def _pl9_core_structure_tables() -> list[str]:
birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {}
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
@@ -6508,12 +6233,6 @@ def render_pl9_markdown(packet: dict) -> str:
out.append('')
return out
def _value_text(*values):
for value in values:
if value not in (None, '', [], {}):
return str(value)
return None
ai_audit_sheet = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {}
ai_summary = ai_audit_sheet.get('summary') if isinstance(ai_audit_sheet.get('summary'), dict) else {}
guided_topics = ai_summary.get('guided_topics') if isinstance(ai_summary.get('guided_topics'), list) else []
@@ -6681,20 +6400,6 @@ def render_pl9_markdown(packet: dict) -> str:
return ';'.join(bits) + '。'
return None
def _compose_narrative_paragraph(*parts: str | None) -> str | None:
cleaned = []
for part in parts:
text = str(part or '').strip()
if not text:
continue
text = text.rstrip()
if text.endswith('。'):
text = text[:-1]
cleaned.append(text)
if not cleaned:
return None
return ';'.join(cleaned) + '。'
dasha_domain_rows = {}
domain_paragraphs = dasha_interpretation_pack.get('domain_paragraphs') if isinstance(dasha_interpretation_pack.get('domain_paragraphs'), list) else []
for row in domain_paragraphs:
@@ -6724,15 +6429,6 @@ def render_pl9_markdown(packet: dict) -> str:
]
special_lagnas_layer = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {}
def _has_executed_payload(value) -> bool:
if value in (None, '', [], {}):
return False
if isinstance(value, dict):
status = str(value.get('status') or value.get('execution_status') or '').strip().lower()
if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}:
return False
return True
def _has_varga_code(varga_code: str) -> bool:
prefix = f'{varga_code}_'
for layer in varga_layers:
@@ -6955,20 +6651,6 @@ def render_pl9_markdown(packet: dict) -> str:
return None
return ';'.join(summary_bits) + '。'
def _planet_list_text(items: list[str], limit: int = 3) -> str | None:
names = []
seen = set()
for item in items:
humanized = _humanize_reader_token(item)
if humanized and humanized not in seen:
seen.add(humanized)
names.append(humanized)
if len(names) >= limit:
break
if not names:
return None
return '、'.join(names)
def _functional_layer_sentence() -> str | None:
if functional_layer.get('status') != 'used':
return None
@@ -7368,11 +7050,6 @@ def render_pl9_markdown(packet: dict) -> str:
annual_evidence_appendix = _envelope_dict(annual_report_sections.get('evidence_appendix'))
annual_field_briefs = _envelope_dict(annual_evidence_appendix.get('field_briefs'))
def _append_unique_text(items: list[str], value: str) -> None:
text = str(value or '').strip().rstrip('。')
if text and text not in items:
items.append(text)
def _reader_executive_summary() -> dict:
ai_audit = packet.get('ai_and_audit') if isinstance(packet.get('ai_and_audit'), dict) else {}
worksheet_ai = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {}
@@ -10762,7 +10439,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 +10467,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 +10495,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 +10507,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 +10518,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
+1
View File
@@ -0,0 +1 @@
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
+49
View File
@@ -0,0 +1,49 @@
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
def _unwrap(raw_value):
# 兼容 packet 字段的 response_envelope 升级:
# 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。
if (
isinstance(raw_value, dict)
and 'result' in raw_value
and 'response_envelope' in raw_value
):
return raw_value.get('result')
return raw_value
def _envelope_list(raw_value):
raw_value = _unwrap(raw_value)
if isinstance(raw_value, list):
return raw_value
if isinstance(raw_value, tuple):
return list(raw_value)
# 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。
if isinstance(raw_value, dict) and 'result' in raw_value:
nested = raw_value.get('result')
if isinstance(nested, list):
return nested
if isinstance(nested, tuple):
return list(nested)
return []
def _envelope_dict(raw_value):
raw_value = _unwrap(raw_value)
return raw_value if isinstance(raw_value, dict) else {}
def _bool_text(value):
return '是' if value else '否'
def _source_dict(*candidates):
for candidate in candidates:
if isinstance(candidate, dict) and candidate:
return candidate
return {}
def _has_executed_payload(value) -> bool:
if value in (None, '', [], {}):
return False
if isinstance(value, dict):
status = str(value.get('status') or value.get('execution_status') or '').strip().lower()
if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}:
return False
return True
+127
View File
@@ -0,0 +1,127 @@
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
try:
from scripts.report_render.text import _md_cell
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.text import _md_cell
def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str:
summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {}
bits = [_md_cell(packet.get('packet_mode'))]
for key, suffix in summary_pairs:
value = summary.get(key)
if value not in (None, '', []):
bits.append(f"{_md_cell(value)}{suffix}")
return f"参考包摘要:{';'.join(bits)}。"
def _status_from_report_pack_section(section: dict) -> str:
if not isinstance(section, dict):
return 'blocked'
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
if section.get('status'):
return str(section.get('status'))
if audit.get('status'):
return str(audit.get('status'))
if section:
return 'partial_verified'
return 'blocked'
def _section_evidence_boundary(section: dict) -> str:
if not isinstance(section, dict):
return 'section_missing'
exports = section.get('exports') if isinstance(section.get('exports'), dict) else {}
ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {}
audit = section.get('audit') if isinstance(section.get('audit'), dict) else {}
boundaries = []
if ai_bundle.get('contains_private_pl9_text') is False:
boundaries.append('no_private_pl9_text')
if ai_bundle.get('claim_policy'):
boundaries.append(str(ai_bundle.get('claim_policy')))
if audit.get('must_not_claim'):
boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3])))
if section.get('reason'):
boundaries.append(str(section.get('reason')))
return '; '.join(boundaries) if boundaries else 'evidence_labeled'
def _domi_pl9_current_gap_appendix() -> list[str]:
return []
def _chart_planets_from_positions(positions: dict) -> dict:
chart_planets = {}
if not isinstance(positions, dict):
return chart_planets
for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
planet = positions.get(planet_name)
if not isinstance(planet, dict):
continue
sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name')
if not sign:
continue
degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0)))
chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0}
return chart_planets
def _kp_house_focus_text(value) -> str:
house_label_map = {
2: '收入与资源',
5: '机会与投入',
6: '职责与压力',
7: '合作与伴侣',
9: '运气与扩张',
10: '事业与位置',
11: '回报与支持',
}
parts = []
if isinstance(value, (list, tuple)):
raw_parts = [str(item).strip() for item in value if str(item).strip()]
else:
raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()]
for part in raw_parts:
try:
house_no = int(part)
except ValueError:
parts.append(part)
continue
parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]")
return '、'.join(parts) if parts else '-'
def _high_value_theme_authority_sentence(domain: str) -> str | None:
domain_label_map = {
'career': '事业',
'wealth': '财务',
'relationship': '关系',
}
action_map = {
'career': '优先排序事业推进顺序、外部舞台和月份节奏',
'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序',
'relationship': '优先排序合作推进、联结温度和关系节奏',
}
return (
"主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与"
+ f"{domain_label_map.get(domain, domain)}章主结论,"
+ f"用来{action_map.get(domain, '补强主题排序')};"
+ "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。"
)
def _kp_conflict_adjudication_sentence(domain: str) -> str:
domain_label_map = {
'career': '事业',
'wealth': '财务',
'relationship': '关系',
}
focus_map = {
'career': '外部舞台、职位变化和项目推进',
'wealth': '资源承接、现金流和回收节奏',
'relationship': '合作推进、联结温度和承诺节奏',
}
return (
f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。"
f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。"
f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。"
)
def _prashna_exact_cusp_boundary_sentence() -> str | None:
return (
"如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;"
"Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。"
)
+255
View File
@@ -0,0 +1,255 @@
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
import re
try:
from scripts.report_render.envelope import _unwrap
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.envelope import _unwrap
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
SIGNS_CN = {'Aries': '白羊座', 'Taurus': '金牛座', 'Gemini': '双子座', 'Cancer': '巨蟹座', 'Leo': '狮子座', 'Virgo': '处女座', 'Libra': '天秤座', 'Scorpio': '天蝎座', 'Sagittarius': '射手座', 'Capricorn': '摩羯座', 'Aquarius': '水瓶座', 'Pisces': '双鱼座'}
PLANET_LABEL_CN = {
'Sun': '太阳',
'Moon': '月亮',
'Mars': '火星',
'Mercury': '水星',
'Jupiter': '木星',
'Venus': '金星',
'Saturn': '土星',
'Rahu': '北交点',
'Ketu': '南交点',
}
def _humanize_reader_token(token: str) -> str:
value = str(token or '').strip()
if not value:
return value
if value in SIGNS_CN:
return SIGNS_CN[value]
if value in PLANET_LABEL_CN:
return PLANET_LABEL_CN[value]
return value
def _humanize_reader_group(raw_group: str) -> str:
seen = set()
parts = []
for item in str(raw_group or '').split(','):
humanized = _humanize_reader_token(item)
if humanized and humanized not in seen:
seen.add(humanized)
parts.append(humanized)
return ' / '.join(parts)
def _humanize_tajika_candidate(name: str) -> str:
return {
'Ithasala_candidate': '趋近相位候选',
'Easarapha_candidate': '分离相位候选',
}.get(str(name or ''), '年度相位候选')
def _humanize_tajika_motion(motion: str) -> str:
return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-'))
def _display_rashi_from_value(raw_value):
if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS):
return _humanize_reader_token(SIGNS[raw_value])
return _humanize_reader_token(raw_value)
def _humanize_reader_summary_line(summary_line: str) -> str:
summary_line = _unwrap(summary_line)
if isinstance(summary_line, dict):
summary_line = (
summary_line.get('brief')
or summary_line.get('text')
or summary_line.get('summary')
or ''
)
text = str(summary_line or '').strip()
if not text:
return text
if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。":
return "这一年的年度主线已经形成。"
if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.":
return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。"
if text == "Source: dasha_master_pack.interpretation_triggers.":
return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。"
if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.":
return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。"
if text == "Tajika Yogas: annual candidate structures visible":
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
if text == "Sahams: annual sensitive points visible":
return "Sahams 当前已经能读到年度敏感点。"
match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text)
if match:
topic = match.group(1).strip()
left = _humanize_reader_group(match.group(2))
right = _humanize_reader_group(match.group(3))
topic_label = {
'Muntha': 'Muntha',
'Year Lord': 'Year Lord',
'Annual Asc': '返照上升',
}.get(topic, topic)
if left == right:
return f"{topic_label} 当前结果指向{left}。"
return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。"
simple_match = re.match(r'^([^:]+):\s*(.+)$', text)
if simple_match:
topic = simple_match.group(1).strip()
value = _humanize_reader_group(simple_match.group(2))
if topic == 'Year Lord' and value:
return f"Year Lord 当前优先候选指向{value}。"
if topic == 'Muntha' and value:
return f"Muntha 当前优先候选指向{value}。"
if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower():
return "Tajika Yogas 当前已经能读到年度候选交互结构。"
if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower():
return "Sahams 当前已经能读到年度敏感点。"
return text
def _humanize_annual_highlight_line(highlight: str) -> str:
text = str(highlight or '').strip()
if not text:
return text
if text == "Solar Return / Tajika annual shell is available.":
return "本年度返照与 Tajika 时间结构已经生成。"
if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.":
return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。"
if text == "The report should read annual structure first, then the evidence appendix.":
return "这一层适合从年度结构展开,再回头核对证据附录。"
return text
def _humanize_annual_narrative_highlight(highlight: str) -> str:
return _humanize_annual_highlight_line(str(highlight)).rstrip('。')
def _humanize_domain_title(title: str) -> str:
text = str(title or '').strip()
if not text:
return text
mapping = {
"Career and public direction": "事业与公众方向",
"Wealth and growth opportunities": "财富与增长机会",
"Relationship and alliance themes": "关系与合作主题",
"Health and vitality caution": "健康与精力提醒",
"Risk, delay, and friction markers": "阻力、延迟与摩擦提示",
"Timing windows and cross-check needs": "时间窗口与交叉核对重点",
}
return mapping.get(text, text)
def _workbook_readable_label(worksheet_id: str) -> str:
if worksheet_id == 'birth_and_parameters':
return '出生与计算口径'
if worksheet_id == 'd1_rasi_bhava':
return '本命盘主结构'
if worksheet_id == 'strengths_and_scores':
return '力量与技术表'
if worksheet_id == 'divisional_and_special_charts':
return '分盘与特殊点位'
if worksheet_id == 'timing_and_predictive_systems':
return '时间系统与流年层'
if worksheet_id == 'advanced_systems':
return '进阶体系'
if worksheet_id == 'ai_and_audit':
return '审计与证据附录'
return '补充工作簿'
def _workbook_readable_sentence(worksheet_id: str) -> str:
if worksheet_id == 'birth_and_parameters':
return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。'
if worksheet_id == 'd1_rasi_bhava':
return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。'
if worksheet_id == 'strengths_and_scores':
return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。'
if worksheet_id == 'divisional_and_special_charts':
return '分盘与特殊点位可作为扩展阅读,适合补充看细节。'
if worksheet_id == 'timing_and_predictive_systems':
return '大运、行运与年度层已经可以形成时间主线。'
if worksheet_id == 'advanced_systems':
return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。'
if worksheet_id == 'ai_and_audit':
return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。'
return f'{worksheet_id} 已纳入当前报告。'
def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str:
parts = []
for worksheet_id in worksheet_ids:
sentence = _workbook_readable_sentence(worksheet_id).rstrip('。')
parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}")
if not parts:
return '当前暂无可用工作簿阅读顺序。'
return ';'.join(parts) + '。'
def _md_cell(value) -> str:
text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip()
return text.replace('|', '/')
def _render_compact_payload(value, depth: int = 0) -> str:
if value in (None, '', [], {}):
return '-'
if depth >= 2:
return _md_cell(type(value).__name__)
if isinstance(value, dict):
parts = []
for key in sorted(value, key=str):
if key in {
'narrative_items',
'domain_paragraphs',
'response_envelope',
'input_hash',
'profile_hash',
'raw_calculation',
'shared_full_report_authority',
}:
continue
parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}")
return '; '.join(parts) if parts else '-'
if isinstance(value, (list, tuple)):
items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]]
suffix = ' …' if len(value) > 8 else ''
return ', '.join(items) + suffix if items else '-'
return _md_cell(value)
def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str:
if not isinstance(row, dict):
return '-'
sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key)
return _md_cell(sign)
def _value_text(*values):
for value in values:
if value not in (None, '', [], {}):
return str(value)
return None
def _compose_narrative_paragraph(*parts: str | None) -> str | None:
cleaned = []
for part in parts:
text = str(part or '').strip()
if not text:
continue
text = text.rstrip()
if text.endswith('。'):
text = text[:-1]
cleaned.append(text)
if not cleaned:
return None
return ';'.join(cleaned) + '。'
def _planet_list_text(items: list[str], limit: int = 3) -> str | None:
names = []
seen = set()
for item in items:
humanized = _humanize_reader_token(item)
if humanized and humanized not in seen:
seen.add(humanized)
names.append(humanized)
if len(names) >= limit:
break
if not names:
return None
return '、'.join(names)
def _append_unique_text(items: list[str], value: str) -> None:
text = str(value or '').strip().rstrip('。')
if text and text not in items:
items.append(text)
+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()
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""Byte-compare phase-1 report rendering against a baseline worktree.
The current tree builds real-engine packets and pickles them outside the repo.
A baseline checkout renders those same packets. Baseline-vs-baseline runs first.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import pickle
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
PRODUCTION_BODY = {
"format": "markdown",
"edition": "full_data",
"include_fact_tables": True,
"languages": ["zh", "en"],
}
FOURTH = {
"name": "虚构丁",
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"node_mode": "mean",
"ayanamsa": "lahiri",
"today": "2026-10-13",
"transit_date": "2026-10-13",
"target_year": 2026,
"age": 36,
"birth_time_accuracy": "confirmed",
}
def _ensure_hashseed() -> None:
if os.environ.get("PYTHONHASHSEED") == "0":
return
env = dict(os.environ)
env["PYTHONHASHSEED"] = "0"
raise SystemExit(subprocess.call([sys.executable, *sys.argv], env=env))
def _activate(root: Path) -> None:
root = root.resolve()
script_dir = str(Path(__file__).resolve().parent)
while script_dir in sys.path:
sys.path.remove(script_dir)
folders = [str(root / "jyotish_vedic"), str(root), str(root / "scripts")]
for folder in folders:
if not Path(folder).exists():
continue
while folder in sys.path:
sys.path.remove(folder)
sys.path.insert(0, folder)
def _mask_pyjhora() -> None:
import importlib
root = Path(tempfile.mkdtemp(prefix="nojhora-"))
package = root / "jhora"
package.mkdir()
(package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8")
sys.path.insert(0, str(root))
prior = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "")
for name in list(sys.modules):
if name == "jhora" or name.startswith("jhora."):
del sys.modules[name]
importlib.invalidate_caches()
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _freeze_clocks() -> None:
import datetime as dt_mod
real = dt_mod.datetime
fixed_utc = real(2026, 10, 13, 0, 0, 0)
fixed_local = real(2026, 10, 13, 8, 0, 0)
class Frozen(real):
@classmethod
def now(cls, tz=None):
if tz is not None:
return fixed_utc.replace(tzinfo=tz)
return fixed_local
@classmethod
def utcnow(cls):
return fixed_utc
for module in list(sys.modules.values()):
if module is None:
continue
current = getattr(module, "datetime", None)
if isinstance(current, type) and current.__name__ == "datetime":
module.datetime = Frozen
def _cases() -> list[tuple[str, dict]]:
from tests.test_report_english_edition import CASES
ordered = [(name, dict(CASES[name])) for name in ("day", "night", "south")]
ordered.append(("fourth", dict(FOURTH)))
return ordered
def _subset_pack_id(packet: dict) -> str | None:
manifest = packet.get("report_pack_manifest") if isinstance(packet, dict) else None
packs = manifest.get("packs") if isinstance(manifest, dict) else None
if not isinstance(packs, list):
return None
for item in packs:
if isinstance(item, dict) and item.get("id") and item.get("id") != "full":
return str(item["id"])
return None
def _build_records() -> list[dict]:
from scripts.jyotish_engine import (
_apply_pl9_pack_selection,
build_professional_report_reference_packet,
cmd_full_reading,
)
from scripts.professional_report_reference import _export_args
records = []
for name, case in _cases():
print(f"build {name}", file=sys.stderr, flush=True)
reading = cmd_full_reading(SimpleNamespace(**case))
packet = build_professional_report_reference_packet(reading, _export_args(case), [])
records.append({"id": f"{name}/reference", "kind": "pl9", "payload": packet})
stripped = copy.deepcopy(packet)
if isinstance(stripped, dict):
stripped.pop("personal_report_producer", None)
records.append({"id": f"{name}/no-producer", "kind": "pl9", "payload": stripped})
pack_id = _subset_pack_id(packet if isinstance(packet, dict) else {})
if pack_id:
selected = _apply_pl9_pack_selection(copy.deepcopy(packet), [pack_id])
records.append({"id": f"{name}/pack/{pack_id}", "kind": "pl9", "payload": selected})
else:
print(f"skip pack {name}: no non-full pack", file=sys.stderr)
records.append({"id": f"{name}/full-data-zh", "kind": "full", "payload": packet})
english = copy.deepcopy(packet)
if isinstance(english, dict):
english["report_language"] = "en"
records.append({"id": f"{name}/full-data-en", "kind": "full", "payload": english})
records.append({
"id": f"{name}/api-production",
"kind": "api",
"payload": {"birth": case, "reading": reading},
})
return records
def _render_text(kind: str, payload) -> str:
if kind == "pl9":
from scripts.jyotish_engine import render_pl9_markdown
return render_pl9_markdown(copy.deepcopy(payload))
if kind == "full":
from scripts.pl9_full_data_export import render_full_data_markdown
return render_full_data_markdown(copy.deepcopy(payload))
if kind == "api":
from scripts.professional_report_reference import build_professional_report_reference
birth = payload["birth"]
reading = copy.deepcopy(payload["reading"])
class Handler:
def _high_rigor_birth_payload(self, _body):
return dict(birth)
def _compute_full_reading_for_thematic(self, _birth):
return reading
response = build_professional_report_reference(Handler(), dict(PRODUCTION_BODY))
return json.dumps(response, ensure_ascii=False, default=str)
raise RuntimeError(f"unknown kind {kind}")
def _render_records(records: list[dict]) -> list[dict]:
import scripts.jyotish_engine # noqa: F401
import scripts.pl9_full_data_export # noqa: F401
import scripts.professional_report_reference # noqa: F401
_freeze_clocks()
rendered = []
for record in records:
text = _render_text(record["kind"], record["payload"])
if not isinstance(text, str):
raise RuntimeError(f"{record['id']} did not render to text")
rendered.append({"id": record["id"], "sha": _sha(text), "text": text})
return rendered
def _diff_context(left: str, right: str) -> str:
left_lines = left.splitlines() or [left]
right_lines = right.splitlines() or [right]
limit = min(len(left_lines), len(right_lines))
index = next((i for i in range(limit) if left_lines[i] != right_lines[i]), limit)
start = max(0, index - 2)
rows = []
for line_no in range(start, start + 5):
sample = left_lines[line_no] if line_no < len(left_lines) else ""
other = right_lines[line_no] if line_no < len(right_lines) else ""
if sample == other and line_no != index:
rows.append(f" {line_no + 1}: {sample}")
continue
cut = _window(sample, other)
rows.append(f"- {line_no + 1}: {cut[0]}")
rows.append(f"+ {line_no + 1}: {cut[1]}")
return "\n".join(rows)
def _window(left: str, right: str) -> tuple[str, str]:
if len(left) <= 240 and len(right) <= 240:
return left, right
limit = min(len(left), len(right))
index = next((i for i in range(limit) if left[i] != right[i]), limit)
start = max(0, index - 80)
stop = index + 80
return left[start:stop], right[start:stop]
def _invoke_baseline(baseline: Path, pickle_path: Path, result_path: Path, mask: bool) -> subprocess.CompletedProcess[str]:
command = [
sys.executable,
str(Path(__file__).resolve()),
"--render-only",
"--pickle",
str(pickle_path),
"--engine-root",
str(baseline),
"--result",
str(result_path),
]
if mask:
command.append("--mask-pyjhora")
env = dict(os.environ)
env["PYTHONHASHSEED"] = "0"
env["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
env["JYOTISH_REPORT_RENDER_FAST"] = "0"
return subprocess.run(
command,
cwd=str(baseline),
env=env,
text=True,
capture_output=True,
check=False,
)
def _load_result(path: Path) -> list[dict]:
return pickle.loads(path.read_bytes())
def _print_shas(rows: list[dict]) -> None:
for row in rows:
print(f"{row['id']}\t{row['sha']}")
def _compare(label: str, left: list[dict], right: list[dict]) -> int:
if [row["id"] for row in left] != [row["id"] for row in right]:
print(f"{label}: record ids differ", file=sys.stderr)
return 1
failed = 0
for lhs, rhs in zip(left, right):
if lhs["sha"] == rhs["sha"]:
continue
failed += 1
print(f"{label} mismatch {lhs['id']} {lhs['sha']} vs {rhs['sha']}", file=sys.stderr)
print(_diff_context(lhs["text"], rhs["text"]), file=sys.stderr)
return 1 if failed else 0
def _render_only(args: argparse.Namespace) -> int:
if args.mask_pyjhora:
_mask_pyjhora()
_activate(Path(args.engine_root))
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
records = pickle.loads(Path(args.pickle).read_bytes())
rendered = _render_records(records)
Path(args.result).write_bytes(pickle.dumps(rendered, protocol=pickle.HIGHEST_PROTOCOL))
return 0
def main() -> int:
_ensure_hashseed()
parser = argparse.ArgumentParser()
parser.add_argument("--baseline", type=Path)
parser.add_argument("--mask-pyjhora", action="store_true")
parser.add_argument("--render-only", action="store_true")
parser.add_argument("--pickle", type=Path)
parser.add_argument("--engine-root", type=Path)
parser.add_argument("--result", type=Path)
args = parser.parse_args()
if args.render_only:
return _render_only(args)
if args.baseline is None:
print("missing --baseline", file=sys.stderr)
return 2
if args.mask_pyjhora:
_mask_pyjhora()
_activate(ROOT)
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
records = _build_records()
temp_dir = Path(tempfile.mkdtemp(prefix="pl9-render-split-"))
try:
pickle_path = temp_dir / "packets.pkl"
pickle_path.write_bytes(pickle.dumps(records, protocol=pickle.HIGHEST_PROTOCOL))
first_path = temp_dir / "baseline-a.pkl"
second_path = temp_dir / "baseline-b.pkl"
first = _invoke_baseline(args.baseline, pickle_path, first_path, args.mask_pyjhora)
second = _invoke_baseline(args.baseline, pickle_path, second_path, args.mask_pyjhora)
if first.returncode != 0 or second.returncode != 0:
print(first.stderr[-4000:], file=sys.stderr)
print(second.stderr[-4000:], file=sys.stderr)
print(first.stdout[-1000:], file=sys.stderr)
return first.returncode or second.returncode or 1
baseline_a = _load_result(first_path)
baseline_b = _load_result(second_path)
if _compare("baseline-vs-baseline", baseline_a, baseline_b) != 0:
print("基线对基线两次 sha 不同,存在非确定性,停止", file=sys.stderr)
return 2
current = _render_records(pickle.loads(pickle_path.read_bytes()))
if _compare("current-vs-baseline", current, baseline_a) != 0:
return 1
_print_shas(current)
print(f"ok items={len(current)} masked={int(args.mask_pyjhora)}")
return 0
finally:
for child in temp_dir.glob("*"):
try:
child.unlink()
except OSError:
pass
try:
temp_dir.rmdir()
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(main())
+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"
}
+203
View File
@@ -0,0 +1,203 @@
"""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")
# 原值:reset(limit=1) 后第二次立刻 HeavyComputeBusy
# 新值:wait_seconds=0
# 原因:默认改为短排队;本测试仍锁「名额被占时同步排盘被拒绝」,等待行为不在这里
gate.reset_heavy_compute_gate(limit=1, wait_seconds=0)
body = {"year": 1990, "lat": 28.6, "lon": 77.2}
slot = gate.acquire_heavy_compute_slot_for_request("/api/chart", body)
assert slot is not None
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:
# 原值:reset(limit=1) 后拒绝日志立刻出现
# 新值:wait_seconds=0
# 原因:默认会先等 8 秒;本测试仍锁日志只有路径、没有请求正文
gate.reset_heavy_compute_gate(limit=1, wait_seconds=0)
held = gate.acquire_heavy_compute_slot("/api/professional_report_reference")
assert held is not None
secret_body = {"birth_date": "1990-01-01", "name": "must-not-appear"}
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()
+89 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import threading
import time
from io import BytesIO
import pytest
@@ -14,6 +15,7 @@ from scripts.api_heavy_compute_gate import (
HEAVY_COMPUTE_PATHS,
HeavyComputeBusy,
acquire_heavy_compute_slot,
heavy_compute_waiter_count,
is_heavy_compute_path,
release_heavy_compute_slot,
reset_heavy_compute_gate,
@@ -93,7 +95,10 @@ def limit_one_gate(monkeypatch):
monkeypatch.setenv("JYOTISH_HEAVY_COMPUTE_CONCURRENCY", "1")
monkeypatch.setenv("JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS", "2")
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "0")
reset_heavy_compute_gate()
# 原值:reset() 使用默认「满了立刻拒绝」
# 新值:wait_seconds=0,本夹具里的 429 断言仍是立刻拒绝
# 原因:产品改为最多等 8 秒;排队成功、等待者满、超时另有测试
reset_heavy_compute_gate(wait_seconds=0)
yield
monkeypatch.delenv("JYOTISH_HEAVY_COMPUTE_CONCURRENCY", raising=False)
monkeypatch.delenv("JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS", raising=False)
@@ -119,7 +124,10 @@ def test_health_and_light_paths_are_not_gated() -> None:
def test_acquire_fail_fast_then_succeeds_after_release() -> None:
reset_heavy_compute_gate(limit=1, retry_after_seconds=3)
# 原值:reset(limit=1, retry_after_seconds=3) 后第二路立刻 HeavyComputeBusy
# 新值:多传 wait_seconds=0
# 原因:默认改为短排队;本测试仍锁 Retry-After=3 与 ERR_COMPUTE_BUSY,不锁「禁止等待」
reset_heavy_compute_gate(limit=1, retry_after_seconds=3, wait_seconds=0)
first = acquire_heavy_compute_slot("/api/high_rigor_workflow")
assert first is not None
with pytest.raises(HeavyComputeBusy) as caught:
@@ -248,8 +256,87 @@ def test_warm_consultation_does_not_consume_the_user_slot(limit_one_gate, monkey
assert "packet" not in warm_box["payload"]
def _wait_until(predicate, timeout: float = 2.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return False
def test_third_request_gets_the_slot_after_one_holder_releases() -> None:
reset_heavy_compute_gate(limit=2, retry_after_seconds=2, wait_seconds=2, max_waiters=4)
first = acquire_heavy_compute_slot("/api/consultation_workflow")
second = acquire_heavy_compute_slot("/api/rectification/v5/score")
assert first is not None and second is not None
found: dict = {}
def _third() -> None:
found["slot"] = acquire_heavy_compute_slot("/api/consultation_workflow")
worker = threading.Thread(target=_third)
worker.start()
assert _wait_until(lambda: heavy_compute_waiter_count() == 1)
release_heavy_compute_slot(first)
worker.join(timeout=3)
assert not worker.is_alive()
assert found["slot"] is not None
release_heavy_compute_slot(second)
release_heavy_compute_slot(found["slot"])
reset_heavy_compute_gate()
def test_waiter_overflow_rejects_immediately() -> None:
reset_heavy_compute_gate(limit=1, retry_after_seconds=2, wait_seconds=2, max_waiters=1)
held = acquire_heavy_compute_slot("/api/high_rigor_workflow")
assert held is not None
parked: dict = {}
def _wait() -> None:
try:
parked["slot"] = acquire_heavy_compute_slot("/api/consultation_workflow")
except HeavyComputeBusy as exc:
parked["error"] = exc
waiter = threading.Thread(target=_wait)
waiter.start()
assert _wait_until(lambda: heavy_compute_waiter_count() == 1)
started = time.monotonic()
with pytest.raises(HeavyComputeBusy) as caught:
acquire_heavy_compute_slot("/api/rectification/v5/score")
assert time.monotonic() - started < 0.3
assert caught.value.retry_after_seconds == 2
assert caught.value.error_code == "ERR_COMPUTE_BUSY"
release_heavy_compute_slot(held)
waiter.join(timeout=3)
assert not waiter.is_alive()
assert parked.get("slot") is not None
release_heavy_compute_slot(parked["slot"])
reset_heavy_compute_gate()
def test_queue_timeout_still_returns_busy() -> None:
reset_heavy_compute_gate(limit=1, retry_after_seconds=2, wait_seconds=0.2, max_waiters=4)
held = acquire_heavy_compute_slot("/api/professional_report_reference")
assert held is not None
started = time.monotonic()
with pytest.raises(HeavyComputeBusy) as caught:
acquire_heavy_compute_slot("/api/consultation_workflow")
elapsed = time.monotonic() - started
assert 0.1 <= elapsed < 2.0
assert caught.value.retry_after_seconds == 2
assert caught.value.error_code == "ERR_COMPUTE_BUSY"
light = acquire_heavy_compute_slot("/api/health")
assert light is None
release_heavy_compute_slot(held)
reset_heavy_compute_gate()
def test_quality_gate_runs_heavy_compute_gate() -> None:
from scripts.run_quality_gate import CORE_PYTEST_TARGETS
assert "tests/test_api_heavy_compute_gate.py" in CORE_PYTEST_TARGETS
assert gate.DEFAULT_CONCURRENCY == 2
assert gate.DEFAULT_QUEUE_WAIT_SECONDS == 8
assert gate.DEFAULT_MAX_WAITERS == 4
+114
View File
@@ -0,0 +1,114 @@
"""Phase-1 report_render package: import boundary and moved-helper parity."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_PLANET_LABEL_CN = {
"Sun": "太阳",
"Moon": "月亮",
"Mars": "火星",
"Mercury": "水星",
"Jupiter": "木星",
"Venus": "金星",
"Saturn": "土星",
"Rahu": "北交点",
"Ketu": "南交点",
}
def test_report_render_imports_without_engine() -> None:
code = "\n".join([
"import sys",
"import scripts.report_render.envelope",
"import scripts.report_render.text",
"import scripts.report_render.sections_misc",
"banned = [name for name in sys.modules if name == 'jyotish_engine' or name.startswith('jyotish_engine.') or name == 'scripts.jyotish_engine' or name.startswith('scripts.jyotish_engine.')]",
"assert banned == []",
])
env = dict(os.environ)
env["PYTHONPATH"] = str(ROOT)
env["PYTHONHASHSEED"] = "0"
completed = subprocess.run(
[sys.executable, "-c", code],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
def test_planet_label_and_signs_match_engine_literals() -> None:
from scripts.jyotish_engine import SIGNS, SIGNS_CN
from scripts.report_render import text as moved
assert list(moved.PLANET_LABEL_CN) == list(ORIGINAL_PLANET_LABEL_CN)
for key, value in ORIGINAL_PLANET_LABEL_CN.items():
assert moved.PLANET_LABEL_CN[key] == value
assert moved.SIGNS == SIGNS
assert moved.SIGNS_CN == SIGNS_CN
def _first(packet, kind: str):
stack = [packet]
while stack:
current = stack.pop()
if kind == "bool" and isinstance(current, bool):
return current
if kind == "str" and isinstance(current, str) and current.strip():
return current
if kind == "list" and isinstance(current, list) and current:
return current
if isinstance(current, dict):
stack.extend(current.values())
elif isinstance(current, (list, tuple)):
stack.extend(list(current))
raise AssertionError(kind)
def test_five_moved_helpers_match_engine_on_real_packet() -> None:
from scripts.jyotish_engine import (
_bool_text,
_envelope_list,
_humanize_reader_token,
_md_cell,
_unwrap,
build_professional_report_reference_packet,
cmd_full_reading,
render_pl9_markdown,
)
from scripts.professional_report_reference import _export_args
from scripts.report_render.envelope import _bool_text as moved_bool
from scripts.report_render.envelope import _envelope_list as moved_list
from scripts.report_render.envelope import _unwrap as moved_unwrap
from scripts.report_render.text import _humanize_reader_token as moved_token
from scripts.report_render.text import _md_cell as moved_cell
from tests.test_report_reader_main import FICTIONAL
reading = cmd_full_reading(SimpleNamespace(**FICTIONAL))
packet = build_professional_report_reference_packet(reading, _export_args(dict(FICTIONAL)), [])
rendered = render_pl9_markdown(packet)
assert isinstance(rendered, str) and len(rendered) > 100
flag = _first(packet, "bool")
label = _first(packet, "str")
rows = _first(packet, "list")
birth = packet.get("birth_info")
assert _bool_text(flag) == moved_bool(flag)
assert _md_cell(label) == moved_cell(label)
assert _unwrap(birth) == moved_unwrap(birth)
assert _humanize_reader_token("Sun") == moved_token("Sun")
assert _humanize_reader_token("Aries") == moved_token("Aries")
assert _envelope_list(rows) == moved_list(rows)
assert _bool_text is moved_bool
assert _md_cell is moved_cell
assert _unwrap is moved_unwrap
assert _humanize_reader_token is moved_token
assert _envelope_list is moved_list
+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)",