Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 ba1b4be71d Merge #119 items 42 and 46 (outage audit, BUG-1463 shadbala API)
Independent Staging Quality Gate / validate (push) Successful in 16m48s
Independent Staging Quality Gate / publish (push) Successful in 4m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:46:25 +08:00
ClaudeandClaude Opus 5.5 8f7814846b docs(ops): outage behaviour audit for VedAstro, location and model providers (#119 item 42)
Code-and-stub audit instead of a live drill (no staging SSH). Per-dependency
tables of what the user sees, charge/refund, logs/alerts and evidence, plus
gaps ranked by risk. Four clear bugs recorded as investigating: BUG-1504
(VedAstro truncated read -> 500), BUG-1505 (report lease expiry keeps
credits), BUG-1506 (profile edit wipes place on timezone failure),
BUG-1507 (legacy China profiles silently +8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:45:55 +08:00
ClaudeandClaude Opus 5.5 eb5e9adcf9 fix(shadbala): move /api/shadbala into shadbala_service; birth data no longer 500 (BUG-1463)
_compute_shadbala called swe.julday with no swe in scope, so any request with
year/month/day/lat/lon raised NameError. The endpoint now lives in
scripts/shadbala_service/api.py, which imports Swiss Ephemeris itself; the
handler only forwards. _shadbala_rule_variants and
_compute_shadbala_advanced_layer moved with it (handler methods 217 -> 215,
baseline lowered). New real-HTTP regression test with ruff F821 on the server,
added to the quick gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:29:30 +08:00
ClaudeandClaude Opus 5.5 7ff14329e9 docs(ops): notes for the 16 migrations added after 10-14 (#119 item 39)
Classifies the 16 production-pending migrations added since the 10-14 list
(A 1, B 3, C 7, D 5) with what production users see and what the rehearsal
should check. The one user-visible rewrite is the first-question-free
backfill: accounts that already completed a real question count as used,
confirmed by the product owner on 2026-11-01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:25:48 +08:00
ClaudeandClaude Opus 5.5 2f6d47c74e feat(release): the release gate refuses placeholder legal details (#119 item 9)
The manual release quality gate now runs the legal-consent test with
JYOTISHA_REQUIRE_LEGAL_ENTITY=1 right after its frontend install, so a
production release fails while legal-entity.ts holds 「待填写」 placeholders
or the terms / privacy text is still marked draft. It was a manual step in
deploy/README.md until now. Approved by the product owner 2026-11-01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:24:40 +08:00
ClaudeandClaude Opus 5.5 618d074c94 feat(deploy): security response headers on every Caddy site (#119 item 33)
A shared (security_headers) snippet, imported first in every site block of the
public, staging and production Caddyfiles: HSTS (one year, no
includeSubDomains), nosniff, strict-origin-when-cross-origin, framing refused
(X-Frame-Options DENY + frame-ancestors 'none'), camera/microphone/
geolocation/payment off, and the Server / X-Powered-By headers dropped.
Approved by the product owner 2026-11-01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:23:58 +08:00
16 changed files with 665 additions and 105 deletions
@@ -97,6 +97,8 @@ jobs:
-r requirements.txt -r requirements-dev.txt playwright
python -m playwright install --with-deps chromium
npm ci --prefix frontend
# Production refuses placeholder operator details and draft terms / privacy text (#119 item 9).
(cd frontend && JYOTISHA_REQUIRE_LEGAL_ENTITY=1 npx tsx --test tests/legal-consent-20260930.test.tsx)
python scripts/run_quality_gate.py --profile release
python -m pytest -q --maxfail=1
python tests/run_all.py
+18
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,5 +28,6 @@
}
www.jyotisha.chat {
import security_headers
redir https://jyotisha.chat{uri} permanent
}
+19
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,6 +28,7 @@
}
https://admin.jyotisha.chat {
import security_headers
encode zstd gzip
@root path /
redir @root /admin 308
@@ -18,5 +36,6 @@ https://admin.jyotisha.chat {
}
https://www.jyotisha.chat {
import security_headers
redir https://jyotisha.chat{uri} 308
}
+18
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://staging.jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,6 +28,7 @@
}
https://admin.staging.jyotisha.chat {
import security_headers
encode zstd gzip
@root path /
redir @root /admin 308
+1 -1
View File
@@ -138,7 +138,7 @@ Before a production release, run the legal-consent test with the production flag
cd frontend && JYOTISHA_REQUIRE_LEGAL_ENTITY=1 npx tsx --test tests/legal-consent-20260930.test.tsx
```
It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operator details or the terms / privacy text is still marked draft. The workflows are not changed for this (they are product-owner territory); the step is manual until the product owner wires it in.
It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operator details or the terms / privacy text is still marked draft. Since 2026-11-01 (product owner approved, #119 item 9) the manual release gate `.gitea/workflows/release-quality-gate.yml` runs this check right after its frontend install, so a production release is refused until the details are filled in and the draft markers are removed.
## Manual production deployment with Gitea Actions
+73 -9
View File
@@ -19935,19 +19935,19 @@
## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500
- 状态:investigating
- 状态:resolved
- 首次发现:2026-10-27(做 issue #114 时调接口发现)
- 最近更新:2026-10-27
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。
- 最近更新:2026-11-01
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。进程内的两处调用(咨询的 `_attach_local_consultation_layers`、专题的 `_derive_thematic_evidence`)只在图里还没有 `modules.shadbala` 时才走这里,以前会被各自的 `except` 记成 `unavailable` / `warning`,不报 500。
- 用户现象:接口返回 `ERR_INTERNAL`。
- 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。
- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。
- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。
- 防复发:待修复单补接口测试。
- 相关记录:issue #114。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。整份文件只有这一处(`ruff --select F821` 实测只报 7564 行)。只带星体经度的旧测试走不到这条分支,所以一直没拦住。
- 修复:新建 `scripts/shadbala_service/api.py`,模块顶部 `import swisseph as swe`,把 `_compute_shadbala` 的出生时刻分支、`_shadbala_rule_variants`、`_compute_shadbala_advanced_layer` 原样移过去。主文件的 `_compute_shadbala` 只转发,并把不支持的 ayanamsa 转成 400。主文件四空格方法 217 → 215,`tests/test_api_server_growth_contract.py` 基线同步降到 215。
- 验证:`tests/test_shadbala_service_api.py` 在本机端口起真实 `JyotishAPIHandler`,POST 金盘出生资料(1990-06-15 12:00 北京,raman,虚构)→ 200;`method_variants` 为精确 Kala / Chesta;七星 Dig Bala 等于同一时刻 `build_shadbala_context` 的宫中点算出的值。只带星体时仍等于 `chart-insight-golden.json` 的 `chart_modules_shadbala`。坏 ayanamsa、缺日月 → 400。把主文件换回修复前版本,同一测试得到 500 和 `NameError: name 'swe' is not defined`。
- 防复发:上面的接口测试已加进 `scripts/run_quality_gate.py` 的 quick 清单;同一文件里还对 `jyotish_api_server.py`、`offline_compute_mixins.py`、`scripts/shadbala_service/` 跑 `ruff --select F821`,再有用了没导入的名字就失败。
- 相关记录:issue #114;Gitea #119 第 46 项。
- 复发自:无
- 修复版本:分支 `codex/rectification-d60-two-way-20261028`。
- 修复版本:分支 `codex/outage-shadbala-119`。
## BUG-1464 | 存过别人的账户进首页即「页面出错了」
@@ -20570,3 +20570,67 @@
- 相关记录:BUG-149(门禁里 npm ci 没有安装期限)。
- 复发自:无
- 修复版本:staging 集成分支 `codex/chart-base-layer-label`。
## BUG-1504 | VedAstro 响应读到一半连接断开时,报告生成与不带 skip 的排盘返回 500
- 状态:investigating
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
- 最近更新:2026-11-01
- 影响面:同步调 VedAstro 官方快照的路径,包括个人报告生成(排盘主入口概览、生时门禁)和不带 `skip_vedastro_main_entry_overview` 的 `/api/chart`。星盘页、对话不受影响:前者总是跳过,后者在后台线程里调用。
- 用户现象:报告以 `workflow_server_error` 失败(会退点);接口返回 `ERR_INTERNAL`。
- 触发条件:VedAstro 已经返回响应头,但响应体读到一半连接被重置或截断(`http.client.IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`)。
- 根因(读码 + 本机 stub 确认 IncompleteRead):`scripts/vedastro_service_adapter.py` 的快照分发(约 2782–2808 行)和区间扫描(约 3010–3046 行)只接住 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 `JSONDecodeError`;`_attach_vedastro_main_entry_overview` 外面也没有 try,所以异常一直冒到请求总入口。
- 修复:未修。本轮只审计,修法待产品决定是否排期(把这几类读错误归为 `network_error`)。
- 验证:本机把 `urlopen` 换成读响应时抛 `IncompleteRead` 的 stub,调用排盘得到 500。脚本是临时的,没有入库;做法见 `docs/operations/outage-behaviour-2026-11-01.md` 第一节。
- 防复发:待修复单补「读错误降级、不 500」的测试。
- 相关记录:Gitea #119 第 42 项;`docs/operations/outage-behaviour-2026-11-01.md`。
- 复发自:无
- 修复版本:未修。
## BUG-1505 | 报告任务最后一次尝试租约过期时,报告标为失败但点数不退
- 状态:investigating
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
- 最近更新:2026-11-01
- 影响面:个人报告、合盘、年运等走 `personal_report_jobs` 的报告任务。
- 用户现象:报告显示失败(`calculation_unavailable`),点数已经扣掉,没有退回。用户重新生成要再付一次,只有删除这份失败报告时,删除触发器才会退点。
- 触发条件:任务已经是最后一次尝试(`max_attempts` 为 3),这时 worker 进程退出、重启或卡死,租约(600 秒)过期,下一轮由回收函数收尾。发版重启时最容易遇到。另一种情况:worker 正常走到失败分支,但 `billing.release` 返回 false 或抛错,返回值被忽略。生时校正的 `release()` 也一样(`frontend/src/lib/rectification-agentic/v9/agent-run-finish.ts:49`)。
- 根因(读码 + 本机 stub 确认):`frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql` 的 `recover_expired_personal_report_jobs` 只把任务和报告改成 failed,不调 `release_usage`;worker 的 tick 也不退点。`frontend/src/lib/personal-report-worker-core.ts:240-243` 不检查 `release` 的返回值。
- 修复:未修。本轮只审计,待产品决定排期。
- 验证:本机 stub 调用回收,`failedCount=1`,没有任何退点调用;`release` 返回 false 时报告照样标失败。脚本是临时的,没有入库。
- 防复发:待修复单补「租约过期终态失败必须退点」「退点失败要重试或告警」的测试。
- 相关记录:BUG-1461(解读失败不让报告失败);`20260925040000_deleted_report_release.sql`;`docs/operations/outage-behaviour-2026-11-01.md`。
- 复发自:无
- 修复版本:未修。
## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地
- 状态:investigating
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
- 最近更新:2026-11-01
- 影响面:用 `BirthPlacePicker` 编辑已存人物的地方(本人资料、人物列表)。新建人物不受影响:新建时会显示错误和「重试」。
- 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。
- 触发条件:编辑已有地点的人物时,时区解析失败。
- 根因(读码 + 本机渲染确认):失败时 `frontend/src/components/birth-place-picker.tsx` 调 `onChange(null)`(约 274 行)。`frontend/src/components/birth-location-fields.tsx:21-35` 把地点代码全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(`birth-place-picker.tsx:129-132`),刚设置的错误状态也一起被丢掉。
- 修复:未修。本轮只审计,待产品决定排期。
- 验证:本机用真实 React 渲染 `BirthPlacePicker`,把 fetch 换成返回 503 的 stub,复现上述现象。脚本是临时的,没有入库。
- 防复发:待修复单补「编辑时时区失败保留原地点并显示重试」的行为测试;现有 `frontend/tests/birth-place-picker.test.ts` 只断言源码文本。
- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节。
- 复发自:无
- 修复版本:未修。
## BUG-1507 | 早期中国出生资料没有时区 ID,被默默按 +8 计算(1986–1991 夏令时出生差一小时)
- 状态:investigating
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计时顺带发现,与故障无关)
- 最近更新:2026-11-01
- 影响面:只有中国省市代码、没有坐标、地点名和 `timezone_id` 的早期资料(`20260724010000_global_birth_locations.sql` 没有回填)。前端首页资料和服务端今日星语、合盘的出生参数都会用到。受影响行数未取证。
- 用户现象:没有报错。1986–1991 年夏季(中国实行夏令时期间)出生的人,按 +8 起盘,应为 +9,差一小时。重新保存本人资料时,`timezone_offset: 8`、`timezone_id: null` 会被写进库。
- 触发条件:上述早期资料参与排盘或被重新保存。
- 根因(读码 + 本机确认):`frontend/src/lib/home-profile.ts:74` 写死 `tz: country.timezone`(8);`frontend/src/lib/global-birth-payloads.ts:36-37`、`:65-66` 在没有 offset 时回落到 `chinaLocations.country.timezone`。新资料走 IANA 历史时区,Python `resolve_timezone_context` 对 1988-07-01 北京给 +9,两条路径结果不一致。
- 修复:未修。需要产品决定:是否回填 `timezone_id=Asia/Shanghai` 并按历史时区重算,以及要不要通知受影响用户。
- 验证:本机 stub 下,今日星语和合盘的出生参数都得到 tz 8,没有联网;早期资料保存后写入 `timezone_offset: 8`、`timezone_id: null`。Python 对同一时刻给 +9。脚本是临时的,没有入库。
- 防复发:待修复单补测试。
- 相关记录:BUG-1506;`docs/operations/outage-behaviour-2026-11-01.md` 第二节。新疆 `Asia/Urumqi`(+6)对报北京时间的用户差两小时,另待产品决定,没有单独记 Bug。
- 复发自:无
- 修复版本:未修。
@@ -0,0 +1,148 @@
# 外部依赖挂掉时网站会怎样(2026-11-01)
Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网站会怎样,没演练过」。
- 做法:没有 staging 的 SSH,所以没做现场断网演练。改成读代码,加上在本机把依赖换成假服务(stub)跑真实代码。stub 没有连任何外部服务,也没有用任何模型密钥。
- 代码版本:分支 `codex/outage-shadbala-119`,基于 `origin/staging` `0f1c179e`。
- 标注:**[实测]** 是在本机跑过代码确认的;**[读码]** 只是读代码得出的结论。
- stub 脚本是一次性的,没有入库。做法写在每张表的「依据」一栏,能照着复现。
- 生产参数按 `deploy/README.md` 取值,例如 `VEDASTRO_TIMEOUT_SECONDS=20`(`deploy/README.md:111`)。
## 先说结论
1. **三类依赖都不会让整站挂掉。** 星盘页、建档完全不依赖 VedAstro 和模型;地点和时区在本机计算,没有外部供应商。
2. **对话的扣点做得对。** 模型失败、超时、中途断流都会退点,测试有覆盖。
3. **报告这一侧最弱。**
- 报告生成要同步等 VedAstro,总耗时没有上限。VedAstro 卡住时会超过前端 90 秒的等待,报告失败。
- 报告任务的最后一次尝试如果因进程重启而租约过期,点数不退。
- 解读段(模型写的部分)失败时,报告照常交付、全额扣点,用户看不到任何提示。
4. **任何一家外部依赖挂了都不会告警。**
- `/api/health` 只检查配置和 swisseph,不探测 VedAstro、模型供应商或时区库。
- VedAstro 模块不写日志。
- 报告侧的模型失败不写 `errorCode`,错误汇总脚本数不到它。
5. **模型调用没有重试,也没有备用供应商。** 一次 429 或 503 就让这一轮失败(会退点)。
## 一、VedAstro(外部参照引擎)
关键参数:
- 单次请求超时 20 秒(生产值)。代码默认值是 4 秒(`scripts/vedastro_service_adapter.py:341`),生产不用它。
- 遇到 429 / 502 / 503 / 504 重试 2 次,间隔 1 秒(`:344-348`)。超时不重试。
- 一次完整快照最多发 24 个 POST。
| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 |
| --- | --- | --- | --- | --- |
| 星盘页 `/api/chart-view` | 不受影响。前端一律带 `skip_vedastro_main_entry_overview: true` | 不涉及 | 无 | `frontend/src/lib/chart-view-load.ts:95` [读码] |
| 建档 / 保存人物 | 不受影响。只预热本地数据包,不调 VedAstro | 不涉及 | 无 | `frontend/src/lib/consult-packet-warm.ts:61` [读码] |
| 对话:VedAstro 超时、5xx 或连不上 | 后台线程去取,前台最多等 1.5 秒。等不到就标 `official_blocked / foreground_optional_evidence_timeout`,回答照常出 | 正常扣点(VedAstro 只是补充证据) | 无日志 | `scripts/vedastro_foreground.py:62-65`;[实测] stub 下前台 1.50 秒返回 |
| 对话:同一张盘当天再问 | 直接用当天的失败记录(#130「失败记一天」),0 秒返回,不再请求 VedAstro | 同上 | 无 | 失败记录存在 `scratch/local/vedastro_snapshot_negative_cache/`,键是出生资料、岁差、交点和 UTC 日期的哈希,UTC 换日后失效(`scripts/vedastro_consultation_snapshot.py:93-189`)。[实测] 第二轮 0.000 秒,`freshness=negative`。测试 `tests/test_vedastro_negative_day_20261028.py` |
| 报告生成:VedAstro 卡住 | 报告任务同步执行两段。一段是排盘时的主入口概览,约 4 × 20 ≈ 80 秒;另一段是生时门禁里的官方快照,约 48 秒。合计约 128 秒,超过前端 90 秒的等待(`frontend/src/mastra/consultation-workflow.ts:149`),报告以 `workflow_timeout` 失败。期间一直占着进程级两个重计算名额中的一个 | 报告任务失败后退点;但见下文「模型供应商」一节的租约过期缺口 | 无 VedAstro 日志 | `frontend/src/app/api/reports/route.ts:384` 和 `frontend/src/lib/personal-report-worker.ts:137` 都没传 `foreground`,所以 `defer_optional_external_evidence=false`。[实测] 把超时缩到 2 秒,排盘段 8.0 秒、门禁段 15.3 秒,按比例推到生产值 |
| 报告生成:VedAstro 返回 5xx 但很快 | 报告完成,VedAstro 一栏标 partial | 正常扣点 | 无 | `frontend/src/lib/personal-report-generation.ts:552` [读码];[实测] 503 下排盘段 27 秒,主要是重试间隔 |
| **报告生成 / 不带 skip 的 `/api/chart`:读响应时连接被截断** | **HTTP 500 `ERR_INTERNAL`,报告以 `workflow_server_error` 失败** | 报告失败后退点 | `[api_server] request failed`(`scripts/jyotish_api_server.py` 请求总入口的 `logging.exception`) | 快照分发只接 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 JSON 错误(`scripts/vedastro_service_adapter.py:2782-2808`、`:3010-3046`)。`http.client.IncompleteRead`、`ConnectionResetError` 漏出去,`_attach_vedastro_main_entry_overview` 外面也没有 try。[实测] IncompleteRead → 500。记为 BUG-1504 |
| 生时校正 `/api/rectification/v5/vedastro-validate`:VedAstro 整体不通 | 11 秒后返回 `status=fail`(`vedastro_timeout`),页面显示不能确认到分钟 | 校正按案例扣点,VedAstro 失败不单独退 | 无 | `scripts/rectification/vedastro_validate_cache.py:36`(11 秒预算)[实测] 11.04 秒 |
| 生时校正同上:VedAstro 连续返回 503 | Python 端要 24 秒,前端 12 秒就放弃(`frontend/src/lib/rectification-agentic/v9/engine-client.ts:942`),按超时失败处理。Python 线程继续跑完,继续占名额 | 同上 | 无 | 11 秒预算只管「能不能开始一次调用」,一次快照调用内部还有 24 个 POST,每个含重试。[实测] 24.05 秒 |
| 生时校正:同一组候选当天再验 | 直接用失败记录,24 小时内不重试;存在进程内存里,重启就丢 | 同上 | 无 | `vedastro_validate_cache.py:33`(`KEEP_SECONDS`)[实测] 第二次 0.00 秒 |
## 二、地点与时区服务
架构上没有外部供应商可挂:
- 出生地选择器是逐级下拉。中国数据随包打包;其他国家读站内静态文件 `/data/world-locations/<cc>.json`(`frontend/src/components/birth-place-picker.tsx:152-155`)。
- 时区走浏览器 → `POST /api/locations/timezone` → Python `/api/location/timezone` → `timezonefinder` + `zoneinfo`,全在本机。[实测] 每次 7–100 毫秒。
- 唯一的外部供应商 Geoapify 只在 `GET /api/locations/search` 里用,而界面上没有任何地方调用这个接口。
所以这里的「挂掉」实际指 Python 容器不可用或卡住。
| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 |
| --- | --- | --- | --- | --- |
| 新建人物,时区接口 503 或连不上 | 「时区服务暂时不可用,稍后重试即可。」加「重试」按钮,草稿保留,「确定」按钮不可点。不能手填经纬度或时区 | 不涉及 | 路由和服务都不写日志 | `birth-place-picker.tsx:272-276`、`:490-493`;`home-onboarding-shell.tsx:137` [读码];[实测] stub 503 / 连接拒绝 / 坏 JSON 都返回 `unavailable` |
| 新建人物,时区接口卡住 | 「正在确认该地点的时区…」一直转,我们的代码没有超时,要等 Node 默认约 300 秒 | 不涉及 | 无 | 选择器到 Next 再到 Python 三段都没有 AbortSignal 或超时 [读码];[实测] stub 卡住时转圈不结束 |
| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506 |
| 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `birth-place-picker.tsx:149-171`、`:336` [读码] |
| 星盘页:本人资料没存时区,查询时时区失败 | 「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」引导去改资料。这是临时故障,文案却让用户以为资料有错;「已经记录下来」实际只是一行 `console.warn`。卡住时 25 秒后显示「这张盘等了太久没有回来…」 | `billed:false` | `console.warn("chart_view_failure", {reason:"timezone_resolver_failure"})`,不告警 | `frontend/src/lib/chart-view-service.ts:190-199`、`chart-view-labels.ts:184`、`:205` [读码];测试 `chart-profile-update-consistency.test.ts`「classifies query, timezone…」 |
| 对话:本人资料没存时区,时区失败 | 503「暂时无法核对完整出生资料…请重新保存后再试,本次不会扣点。」同样把临时故障说成资料问题 | **不扣点**:时区在 `reserve()` 之前解析 | 不写日志 | `consultation-route-service.ts:567-574` 对比 `:622` [读码];测试 `consultation-route-service.test.ts`「historical timezone failure remains pre-billing」[实测通过] |
| 报告、合盘、今日星语 | 不受影响:这些入口要求资料里已存时区,不再联网解析 | 不涉及 | 不涉及 | `frontend/src/lib/subject-birth.ts:83-91` [读码] |
| 生时校正开案 | 500「校正服务暂时不可用」 | 不扣点:时区在开案 RPC 之前 | 不写日志 | `frontend/src/lib/rectification-agentic/v9/case-service.ts:479-481` 对比 `:512` [读码] |
| 生时评估流程 | 503「生时评估暂时不可用 / 已保留当前资料,请稍后重试。」只有这一处文案准确 | 不涉及 | 不写日志 | `frontend/src/app/api/birth-time-journey/route.ts:254-260` [读码] |
| 星历页 | 本人盘部分直接变空,没有提示 | 不涉及 | 无 | `frontend/src/app/api/ephemeris/route.ts:124` [读码] |
顺带发现的正确性问题,与故障无关:
- 早期中国资料只有省市代码,没有坐标和 `timezone_id`。这类资料在前后端都会被默默按 +8 计算,重新保存时还会把 `timezone_offset: 8` 写进库。
- 出处:`frontend/src/lib/home-profile.ts:74`、`frontend/src/lib/global-birth-payloads.ts:36-37`、`:65-66`。
- 1986–1991 年夏季(中国实行夏令时)出生的人,正确值是 +9,所以差一小时。[实测] Python 对 1988-07-01 北京给 +9。
- 记为 BUG-1507。
- `timezonefinder` 把乌鲁木齐、喀什定为 `Asia/Urumqi`(+6)[实测]。如果用户报的是北京时间,会差两小时。要不要处理,需要产品决定。
## 三、模型供应商
关键参数:
- 模型目录从数据库读(`frontend/src/lib/model-catalog.ts:60-77`),`frontend/src/mastra/model.ts` 只是占位。
- **不重试**:Mastra 智能体的 `maxRetries` 默认是 0,项目里没有任何地方改它。[实测] 503、429、连接拒绝都只发了 1 次请求。
- **没有备用供应商**:`frontend/src/lib/consult/session-model-fallback.ts:33-50` 只在会话固定的模型版本被下架时换成默认模型,调用失败时不换。
- 对话的计时:工具阶段 110 秒(`frontend/src/mastra/consultation-tools.ts:77`),回答阶段另计 300 秒(`:108`)。浏览器端没有超时。
- 报告解读:每次 120 秒,最多 2 次(`personal-report-reading.ts:33`、`:183`)。
- 合盘:每节 90 秒,最多 2 次。
- 生时校正:每次 210 秒,每轮最多 225 秒、2 次(`rectification-run-budget.ts:8-11`)。
| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 |
| --- | --- | --- | --- | --- |
| 对话:供应商 503、429 或连不上 | 「咨询暂时无法完成,本次不会扣点。」输入框解锁。没有自动重试,也不提示换模型 | **退点**(`frontend/src/app/api/consult/route.ts:1641-1645` → `settlement.ts:143-167`) | `[consult-provider-error]`,`[agent-observability]` 带 `errorCode=provider_error`。错误汇总脚本 5 分钟内同类满 10 次才告警 | [实测] 真实 Mastra 智能体 + 本机 stub 供应商。测试「generic provider stream error is calculation_failed」(BUG-938) |
| 对话:回答写到一半断流 | 已经显示的文字保留,后面接失败提示。保留的文字只在浏览器里,刷新就没了 | 退点 | 同上 | [实测] 已送出 283 字后断流,`run.failed` |
| 对话:供应商接了连接但不回 | 转圈加进度文字,最长约 300 秒;工具阶段卡住时最坏约 410 秒。最后显示「计算已完成,但这次没有生成回答…请再发送一次」,文案不对 | 退点 | `errorCode=empty_answer` | [实测] 把回答时钟缩到 2 秒复现 |
| 对话:浏览器断开或服务器进程退出 | 页面转入「回答仍在后台生成…」,每 1.75 秒查一次状态。15 分钟后显示「回答已取消…」 | 进程还在:正常结算。进程已退出:15 分钟后由状态接口补退,前提是用户页面还在查 | 无 | `frontend/src/app/api/consult/status/route.ts:79-106` [读码];测试「reserved consultations recover through the status endpoint」 |
| 个人报告:解读段模型失败(2 × 120 秒) | 报告显示「已完成」,但没有解读段,也没有任何提示(BUG-1461 的设计是解读失败不让报告失败) | **全额扣点** | 只有 `console.info [personal-report] reading=model_failed`,不告警 | `frontend/src/lib/personal-report-worker-core.ts:436-451` [读码] |
| 合盘 / 年运报告:模型失败 | 任务最多试 3 次,然后显示「报告生成失败」和错误码。页面 8 分钟后停止轮询并显示超时 | 最终失败时退点 | 没有 observability 行,不告警 | `synastry-full-run.ts:213-219`、`annual-report-server.ts:463-467` [读码];测试「terminal durable generation failure releases…」 |
| **报告任务最后一次尝试时进程重启或卡死(租约过期)** | 报告变成失败(`calculation_unavailable`) | **不退点。** 回收函数 `recover_expired_personal_report_jobs` 只改状态、不调 `release_usage`;只有用户删除报告时才由删除触发器退回。用户重新生成就要再付一次 | 无 | `frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql`(回收函数)对比 `20260925040000_deleted_report_release.sql`。[实测] stub 下回收 `failedCount=1`、没有任何退点调用。记为 BUG-1505 |
| 报告或校正的退点 RPC 本身失败 | 报告照常标失败 | 不退,返回值被忽略 | 无 | `personal-report-worker-core.ts:242`;`rectification-agentic/v9/agent-run-finish.ts:49` [实测 报告侧] |
| 生时校正:模型失败 | 自动重试一次,然后「本轮没有完成,状态已记录。」中途失败时可能显示「服务端运行超时」,但实际不是超时 | 退点(返回值被忽略,同上) | `[rectification-v10] attempt failed …` | `v9/agent-run-attempt.ts:434`、`:503-514` [读码] |
| 星盘页 / 建档 | 不调用模型 | 不涉及 | 不涉及 | [读码] |
| 旧版对话路径(`CONSULTATION_AGENTIC_RUNTIME=legacy`)中途出错 | 文字流中断 | 按已输出部分扣点 | 无 | `consult/route.ts:1683-1690` [读码]。默认是 `enabled`,这条路径目前不走 |
## 四、告警现状(三类依赖共用)
- 探活脚本 `deploy/monitor/healthcheck-alert.sh` 只看 `/api/health`。
- Next 侧检查的是数据库迁移、模型目录配置和 Python 是否在线。
- Python 侧只检查 swisseph。
- 都不探测 VedAstro、模型供应商或时区库,所以三者挂掉时探活仍是绿的。
- 错误汇总 `deploy/monitor/error-digest.sh` 只数 `[agent-observability]` 的 `errorCode` 和 5xx。
- 对话失败是 200 流里夹 `run.failed`,只能靠 `errorCode` 计数。
- VedAstro、地点、报告侧的模型失败都不写 `errorCode`。
- 第 20 项(飞书 Webhook、在主机上安装监控)还没做,所以以上告警现在都不会发出去。
## 五、缺口(按风险从高到低)
1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。
2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。
3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。
4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**
5. **早期中国资料默默按 +8 计算(BUG-1507)。** 这是正确性问题,与故障无关:1986–1991 年夏季出生的差一小时。新疆 +6 要产品决定。
6. **模型调用不重试、没有备用供应商。** 一次 429 或 503 就让付费用户这一轮失败。会退点,但体验差;目录里配了多个模型,失败时也不切换。
7. **外部依赖挂了没有任何告警或日志。** 健康检查不探测,VedAstro 模块不写日志,报告侧模型失败不写 `errorCode`。凌晨低流量时,对话也达不到 5 分钟 10 次的阈值。
8. **个人报告解读失败仍全额扣点,用户不知道。** 这是 BUG-1461 的设计结果,需要产品决定:是否降价、提示用户,或者重试。
9. **生时校正的 VedAstro 验证预算在 5xx 下失效。** Python 端 24 秒,前端 12 秒放弃,线程继续占名额。
10. **VedAstro 失败缓存不会被成功覆盖。** 对话按 UTC 日期,校正按 24 小时;一次 5 分钟的短暂故障,会让这张盘当天都拿不到官方证据。
11. **超时与文案。**
- 地点到 Python 的整条链没有超时,卡住时转圈约 5 分钟。
- 对话卡住时最长约 300 秒,之后显示「计算已完成…」。
- 星盘页和对话把临时故障说成「资料不对、请重新保存」。
- 星盘页声称「已经记录下来了」,实际只有 `console.warn`。
12. **低风险。**
- 没有页面调用的 `/api/locations/search` 仍保留 Geoapify 路径:没有超时,供应商返回非 JSON 时 500。
- 海外国家列表没有重试按钮;星历页默默丢掉本人盘。
- 中途断流时保留的部分回答,刷新后就没了。
- `tests/test_vedastro_service_adapter_executor.py` 有 3 个用例在 teardown 报错:用例把 `time.monotonic` 换成了有限迭代器,conftest 收尾时再调用它就抛 `StopIteration`。这是测试本身的问题,不在 quick 门禁里。
## 六、本轮跑过的现有测试
- Python:`tests/test_vedastro_negative_day_20261028.py`、`test_rectification_v5_vedastro_validation.py`、`test_vedastro_snapshot_cache.py`、`test_vedastro_consultation_snapshot.py`、`test_vedastro_health_probe_cache.py`、`test_vedastro_service_adapter_executor.py`、`test_vedastro_gateway.py`、`test_vedastro_free_tier_state.py`、`test_vedastro_executor_isolation_20261028.py`、`test_vedastro_runtime_ops.py`、`test_vedastro_rest_bridge.py`。结果:107 通过;另有 3 个 teardown 报错,见上文第 12 条。
- 前端,地点:`global-birth-location`、`birth-place-picker`、`global-birth-profile-routes`、`adopted-birth-date`、`consultation-route-service`,50/50 通过。
- 前端,模型与扣点:`consultation-agentic-runtime`、`consultation-billing`、`consultation-recovery`、`consultation-stream-recovery`、`consultation-recovery-poll`、`stream-text-response`、`consult-answer-truncation-20260926`、`consultation-smalltalk`、`consult-session-model-fallback`、`model-catalog`、`personal-report-worker`、`synastry-full-report`、`rectification-v9-stream`、`session-title-agent`、`session-context-summary`、`report-reading-20261027`。结果:307 项中 306 通过;1 项失败是因为本机 Node 环境没有 swisseph,属于环境问题。
- 没有测试覆盖的:
- 任何一段的超时或卡住
- 模型重试和备用供应商
- 租约过期后的退点,以及退点 RPC 失败
- 选择器的出错、重试和编辑时清空
- 时区失败时对话和校正的文案
- 早期中国资料的 +8 回退
@@ -89,3 +89,35 @@
| `20261001010000_rectification_segment_adoption.sql` | C | 校正可以把一段时间里的某一分钟记为采用结果,并收紧权限。 | 这不是新的引擎候选,也不等于最终确认。 | 迁移无报错。 |
| `20261001020000_rectification_segment_checks.sql` | C | 校正时段结果的检查收紧。 | 不改已采用的时间。 | 迁移无报错。 |
| `20261002010000_rectification_dated_algorithm_generation.sql` | D | 校正记下算法版本,方便以后区分新旧打分。 | 不改已经交付的结果。 | 迁移无报错。 |
## 2026-10-14 之后新增的 16 条(2026-11-01 补,#119 第 39 条)
对照 `origin/main`(`7b620c7a`)与 staging `0f1c179e`,`frontend/supabase/migrations` 在上面 63 条之后又新增 16 个文件,没有修改或删除旧文件;`frontend/db/migrations` 没有新增。分级同上:去掉函数体之后看顶层语句。复核后 A 1、B 3、C 7、D 5。
### 产品需知(新增)
- `20261022190000_onboarding_first_consult_and_relation`:**老用户的「首问免费」算已经用过**(jesse 2026-11-01 确认)。迁移里的 `backfill_first_consult_free()` 只处理已经完成过一次正式提问(不算寒暄)的账户,把「首问免费已用」记成第一次完成的时间;从没提过问的账户仍保留一次免费。演练看函数返回的行数。
- `20261017010000_chart_profiles_limit_20`:每人能保存的其他人从 4 位放宽到 19 位(加上本人共 20)。只改上限,不动已有档案。
- `20261020010000_synastry_full_pricing` 和 `20261101120100_annual_full_pricing`:分别写入合盘报告 19 点、年运报告 20 点(standard / premium / internal 三档,已发布)。后台已经发布过同一功能价格的,不覆盖。
- `20261101010000_rectification_chart_subject`(#160):删除人物档案时,TA 的校正记录一并删除,扣过的点不退。老校正记录都算本人的,不改。
### 16 条
| 文件 | 级 | 作用 | 对生产用户 | 演练要看 |
| --- | --- | --- | --- | --- |
| `20261014010000_chart_profiles_service_role_select.sql` | D | 补服务角色读人物表的权限(BUG-1063)。 | 不改数据。 | 跑完后服务角色能读人物表(见上面冒烟)。 |
| `20261017010000_chart_profiles_limit_20.sql` | D | 人物档案上限的触发器函数,阈值从 4 改成 19。 | 能多存人,已有档案不动。 | 迁移无报错。 |
| `20261018010000_synastry_reports_marriage_match.sql` | C | 合盘报告表加婚配列、唯一索引和触发器,并收紧权限。 | 不改已有报告。 | 迁移无报错。 |
| `20261019010000_synastry_full_report.sql` | C | 完整合盘报告:报告表加列、外键,计费键允许列表加 `synastry.full`,并收紧权限。 | 不改已有报告和订单。 | 迁移无报错;允许列表没改到会直接报错停下。 |
| `20261019020000_synastry_report_chat.sql` | C | 合盘报告进对话问依据:会话加列、索引和函数,并收紧权限。 | 旧对话不改。 | 迁移无报错。 |
| `20261020010000_synastry_full_pricing.sql` | B | 写入合盘报告价格 19 点(已发布)。 | 用户能看到并购买合盘报告;已发布过的不覆盖。 | 迁移后 `feature_pricing` 里 `synastry.full` 三行。 |
| `20261020150000_admin_model_version_draft_and_delete.sql` | C | 后台模型版本可以存草稿和删除,并收紧权限。 | 用户无感。 | 迁移无报错。 |
| `20261021120000_synastry_ready_reports_allow_repeat.sql` | B | 去掉「同一对人只能有一份完成的合盘报告」的唯一索引。 | 同一对人可以再出一份,旧报告保留。不改数据。 | 迁移无报错。 |
| `20261022190000_onboarding_first_consult_and_relation.sql` | A | 首问免费和人物关系:账户加首问免费列,人物加关系列,回填已提过问的账户为已用,并收紧权限。 | 已经正式提过问的老用户没有首问免费(产品已确认)。 | 记下 `backfill_first_consult_free()` 返回的行数。 |
| `20261025010000_chat_session_sidebar_order.sql` | D | 会话加侧栏排序列。 | 不改已有会话。 | 迁移无报错。 |
| `20261025180000_chart_profiles_archive_order.sql` | D | 人物档案加排序列。 | 不改已有档案。 | 迁移无报错。 |
| `20261026120000_sidebar_archive_order_batch.sql` | C | 侧栏和档案批量排序的函数,并收紧权限。 | 不改已有顺序。 | 迁移无报错。 |
| `20261028170000_rectification_entry_summary_detail.sql` | C | 校正入口摘要多给几项明细,并收紧权限。 | 不改已有校正。 | 迁移无报错。 |
| `20261101010000_rectification_chart_subject.sql` | C | 给他人做校正:校正记录加「是谁的盘」,采用时间只写回那一位,删人物时一起删校正记录,并收紧权限(BUG-1502 在 staging 门禁真库测试修正过)。 | 老校正记录都算本人;删人物会连带删 TA 的校正。 | 迁移无报错;老记录 `chart_profile_id` 全为空。 |
| `20261101120000_annual_full_report.sql` | D | 年运报告类型、结构版本和计费键 `annual.full`,改写计费键允许列表约束。 | 不改已有报告。 | 迁移无报错;允许列表没改到会直接报错停下。 |
| `20261101120100_annual_full_pricing.sql` | B | 写入年运报告价格 20 点(已发布)。 | 用户能看到并购买年运报告;已发布过的不覆盖。 | 迁移后 `feature_pricing` 里 `annual.full` 三行。 |
@@ -1127,6 +1127,12 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
assert.match(workflow, /H relative to R is docs-only/);
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
// #119 item 9: the release gate refuses placeholder operator details and draft legal text,
// right after the frontend install and before the long checks.
assert.match(
workflow,
/npm ci --prefix frontend\n\s+# [^\n]*\n\s+\(cd frontend && JYOTISHA_REQUIRE_LEGAL_ENTITY=1 npx tsx --test tests\/legal-consent-20260930\.test\.tsx\)\n\s+python scripts\/run_quality_gate\.py --profile release/,
);
for (const dependency of [
"mcp==1.28.1",
"pydantic==2.13.4",
@@ -123,3 +123,28 @@ test("user-facing Caddy files do not let browsers keep the chat HTML across rele
assert.match(caddy, /header @htmlDocuments Cache-Control "private, no-store, must-revalidate"/);
}
});
test("every Caddy site sends the security response headers (issue #119 item 33)", () => {
for (const caddy of [stagingCaddy, productionCaddy, publicCaddy]) {
const snippet = caddy.match(/^\(security_headers\) \{\n([\s\S]*?)\n\}\n/m)?.[1] ?? "";
assert.ok(snippet, "security_headers snippet missing");
for (const line of [
'Strict-Transport-Security "max-age=31536000"',
'X-Content-Type-Options "nosniff"',
'Referrer-Policy "strict-origin-when-cross-origin"',
'X-Frame-Options "DENY"',
`Content-Security-Policy "frame-ancestors 'none'"`,
'Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"',
"-Server",
"-X-Powered-By",
]) {
assert.ok(snippet.includes(line), line);
}
// HSTS stays per host: the subdomains are listed one by one, not all covered by TLS here.
assert.doesNotMatch(snippet, /includeSubDomains|preload/);
// Every site block imports it as its first directive.
const sites = [...caddy.matchAll(/^(?!\(security_headers\))\S[^\n]*\{\n([^\n]*)/gm)];
assert.ok(sites.length >= 2);
for (const site of sites) assert.equal(site[1], " import security_headers", site[0]);
}
});
+6 -94
View File
@@ -7545,41 +7545,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
}
def _compute_shadbala(self, body):
planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body)
if not {'Sun', 'Moon'} <= set(planet_lons):
raise BadRequest('planets must include Sun and Moon longitude data')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
birth_second = self._get_birth_second(body)
birth_hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second)
shadbala_module = _load_local_module('shadbala')
context = None
if all(key in body for key in ('year', 'month', 'day', 'lat', 'lon')):
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
jd = swe.julday(year, month, day, birth_hour_decimal - tz)
context = shadbala_module.build_shadbala_context(jd, lat, lon, _request_ayanamsa(body), tz)
result = shadbala_module.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour_decimal,
planet_lons['Sun'],
planet_lons['Moon'],
birth_minute,
context=context,
)
advanced = self._compute_shadbala_advanced_layer(body, planets, result)
return {
'success': True,
'endpoint': 'shadbala',
'rule_variants': self._shadbala_rule_variants(bool(advanced)),
'advanced_layer': advanced,
'result': result,
}
"""Thin registration of scripts/shadbala_service/api.py (BUG-1463)."""
shadbala_api = _load_local_module('shadbala_service.api')
try:
return shadbala_api.compute_shadbala(self, body)
except shadbala_api.ShadbalaInputError as exc:
raise BadRequest(str(exc)) from exc
def _compute_yogas_api(self, body):
planets, _, asc_sign_idx = self._normalized_planets_from_body(body)
@@ -7624,16 +7595,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。',
}
def _shadbala_rule_variants(self, advanced_enabled):
return {
'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'],
'available': [
{'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'},
{'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'},
],
'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。',
}
def _yoga_rule_variants(self, curse_enabled):
return {
'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []),
@@ -7673,55 +7634,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
legacy[planet] = row
return legacy
def _compute_shadbala_advanced_layer(self, body, planets, base_result):
try:
advanced_mod = _load_local_module('shadbala_advanced')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
birth_second = self._get_birth_second(body)
year = self._get_int(body, 'year', datetime.now().year, 1800, 2400)
month = self._get_int(body, 'month', 1, 1, 12)
day = self._get_int(body, 'day', 1, 1, 31)
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180)
tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second)
solar_lon = planets.get('Sun', {}).get('lon', 0)
base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
'degree': pdata.get('lon', pdata.get('degree', 0)),
'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
'source': 'scripts/shadbala_advanced.py',
'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层',
'kala_vmdh': kala_additions,
'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala],
'yuddha_bala': yuddha,
'active_yuddha': active_yuddha,
'sputa_drik_bala': drik_sputa,
'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。',
}
except Exception as exc:
import logging
logging.warning(f"[api_server] shadbala advanced layer failed: {exc}")
return {}
def _compute_aspects(self, body):
planets, planet_lons = self._planet_lons_from_body(body)
if len(planet_lons) < 2:
+2
View File
@@ -137,6 +137,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_runtime_jhora_import_scan.py",
# English edition han leak, 12-chart slice (issue #89 / BUG-1413). The 200-chart file stays off this list.
"tests/test_report_english_sweep_smoke.py",
# /api/shadbala with the birth moment over real HTTP, plus ruff F821 on the API server (BUG-1463).
"tests/test_shadbala_service_api.py",
]
RUNTIME_TRUTH_PYTEST_TARGETS = [
+5
View File
@@ -0,0 +1,5 @@
"""Shadbala endpoint service (BUG-1463).
- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for
``/api/shadbala`` and the in-process callers of ``_compute_shadbala``.
"""
+154
View File
@@ -0,0 +1,154 @@
"""`/api/shadbala`: the six-fold strength core plus the advanced evidence layer.
Moved out of ``JyotishAPIHandler`` for BUG-1463. The handler method used
``swe.julday`` with no ``swe`` in scope, so every request carrying
year/month/day/lat/lon failed with ``NameError`` (HTTP 500). This module
imports Swiss Ephemeris itself; the handler only forwards the body.
The handler is passed in for its existing body parsers (``_get_float``,
``_parse_timezone`` ...). They raise the server's ``BadRequest`` and that
propagates unchanged. This module never imports the HTTP server.
"""
from __future__ import annotations
import logging
from typing import Any
import swisseph as swe
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
BIRTH_KEYS = ("year", "month", "day", "lat", "lon")
logger = logging.getLogger(__name__)
class ShadbalaInputError(ValueError):
"""The request names an ayanamsa the engine does not support."""
def _ayanamsa(body: dict[str, Any]) -> str:
"""Same reading as the server's ``_request_ayanamsa(body)``."""
raw = body.get("ayanamsa") or body.get("ayanamsa_name") or body.get("ayanamsa_policy")
if raw in (None, ""):
return DEFAULT_AYANAMSA_NAME
try:
return normalize_ayanamsa_name(raw)
except UnsupportedAyanamsaError as exc:
raise ShadbalaInputError(str(exc)) from exc
def birth_context(handler: Any, body: dict[str, Any], birth_hour: float, birth_minute: float, birth_second: float) -> dict[str, Any] | None:
"""Precise Swiss Ephemeris context when the body carries the full birth moment."""
if not all(key in body for key in BIRTH_KEYS):
return None
import shadbala
year = handler._get_int(body, "year", 1990, 1800, 2400)
month = handler._get_int(body, "month", 6, 1, 12)
day = handler._get_int(body, "day", 15, 1, 31)
lat = handler._get_float(body, "lat", 0, -90, 90)
lon = handler._get_float(body, "lon", 0, -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
jd_ut = swe.julday(year, month, day, hour_decimal - tz)
return shadbala.build_shadbala_context(jd_ut, lat, lon, _ayanamsa(body), tz)
def rule_variants(advanced_enabled: bool) -> dict[str, Any]:
return {
"selected": ["core_sixfold", "advanced_evidence"] if advanced_enabled else ["core_sixfold"],
"available": [
{"key": "core_sixfold", "label": "六重力量主算法", "status": "active", "source": "scripts/shadbala.py"},
{"key": "advanced_evidence", "label": "Kala/Yuddha/Sputa 增强证据", "status": "active" if advanced_enabled else "unavailable", "source": "scripts/shadbala_advanced.py"},
],
"boundary": "增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。",
}
def advanced_layer(handler: Any, body: dict[str, Any], planets: dict[str, Any], base_result: Any) -> dict[str, Any]:
"""Kala sub-items, Yuddha and Sputa Drishti evidence; ``{}`` when it cannot be built."""
from datetime import datetime
try:
import shadbala_advanced as advanced_mod
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
year = handler._get_int(body, "year", datetime.now().year, 1800, 2400)
month = handler._get_int(body, "month", 1, 1, 12)
day = handler._get_int(body, "day", 1, 1, 31)
lat = handler._get_float(body, "lat", body.get("birth_lat", 0), -90, 90)
lon = handler._get_float(body, "lon", body.get("birth_lon", 0), -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
solar_lon = planets.get("Sun", {}).get("lon", 0)
base_planets = base_result.get("planets", {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
"degree": pdata.get("lon", pdata.get("degree", 0)),
"shadbala_total": base_planets.get(planet, {}).get("total_virupas", 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
"source": "scripts/shadbala_advanced.py",
"method": "Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层",
"kala_vmdh": kala_additions,
"top_kala_support": [{"planet": planet, "virupas": value} for planet, value in top_kala],
"yuddha_bala": yuddha,
"active_yuddha": active_yuddha,
"sputa_drik_bala": drik_sputa,
"next_action": "若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。",
}
except Exception as exc:
logger.warning("[api_server] shadbala advanced layer failed: %s", exc)
return {}
def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
import shadbala
planets, planet_lons, asc_sign_idx = handler._normalized_planets_from_body(body)
if not {"Sun", "Moon"} <= set(planet_lons):
raise ShadbalaInputError("planets must include Sun and Moon longitude data")
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
birth_hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
context = birth_context(handler, body, birth_hour, birth_minute, birth_second)
result = shadbala.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour_decimal,
planet_lons["Sun"],
planet_lons["Moon"],
birth_minute,
context=context,
)
advanced = advanced_layer(handler, body, planets, result)
return {
"success": True,
"endpoint": "shadbala",
"rule_variants": rule_variants(bool(advanced)),
"advanced_layer": advanced,
"result": result,
}
+5 -1
View File
@@ -38,7 +38,11 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217
# 原值:217
# 新值:215
# 原因:BUG-1463 把 /api/shadbala 移到 scripts/shadbala_service/api.py,
# 删除 _shadbala_rule_variants 与 _compute_shadbala_advanced_layer,_compute_shadbala 只转发
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 215
# 原值:31
# 新值:5
+151
View File
@@ -0,0 +1,151 @@
"""BUG-1463: `/api/shadbala` with the birth moment returned 500 (`NameError: swe`).
The endpoint now lives in scripts/shadbala_service/api.py and the handler only
forwards the body. These tests go through the real HTTP handler on a local
port, so the regression is caught at the same surface the caller saw.
Birth data is the fictional golden profile shared with the frontend tests
(1990-06-15 12:00 Beijing, raman).
"""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
import swisseph as swe
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
for path in (ROOT, SCRIPTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate # noqa: E402
from scripts.shadbala import build_shadbala_context, calc_dig_bala_precise # noqa: E402
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
PLANETS = GOLDEN["chart"]["planets"]
ASCENDANT = GOLDEN["chart"]["ascendant"]
BIRTH = {
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
}
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
@pytest.fixture(scope="module")
def api_base():
import os
from scripts.jyotish_api_server import DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, JyotishAPIHandler
previous = os.environ.get("JYOTISH_API_RATE_LIMIT_PER_MINUTE")
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = "0"
reset_heavy_compute_gate()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
server = ThreadingHTTPServer(("127.0.0.1", port), JyotishAPIHandler)
server.daemon_threads = True
base = f"http://127.0.0.1:{port}"
server.allowed_origins = set(DEFAULT_ALLOWED_ORIGINS) | {base}
server.allowed_hosts = set(DEFAULT_ALLOWED_HOSTS)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield base
finally:
server.shutdown()
server.server_close()
reset_heavy_compute_gate()
if previous is None:
os.environ.pop("JYOTISH_API_RATE_LIMIT_PER_MINUTE", None)
else:
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = previous
def _post(base: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
request = urllib.request.Request(
f"{base}/api/shadbala",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", "Origin": base, "Host": base.split("://", 1)[1]},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8") or "{}")
def test_birth_data_request_returns_precise_shadbala(api_base) -> None:
status, payload = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert status == 200, payload
assert payload["success"] is True
result = payload["result"]
# The birth moment switches Kala and Chesta to the Swiss Ephemeris context.
assert result["method_variants"] == {
"kala": "bphs_local_solar_events_ahargana_declination",
"chesta": "bphs_bounded_surya_mean_motion_seeghrochcha",
}
assert sorted(result["ranking"]) == sorted(SEVEN)
for planet in SEVEN:
row = result["planets"][planet]
assert row["total_rupas"] > 0
assert row["total_virupas"] == pytest.approx(row["total_rupas"] * 60, abs=0.05)
# Dig Bala is read from the Placidus bhava midpoints of the same instant (UT = 12:00 - 8h).
context = build_shadbala_context(swe.julday(1990, 6, 15, 4.0), 39.9042, 116.4074, "raman", 8.0)
for planet in SEVEN:
expected = calc_dig_bala_precise(planet, float(PLANETS[planet]["lon"]), context["house_midpoints"])
assert result["planets"][planet]["dig_bala"] == expected
assert payload["rule_variants"]["selected"] == ["core_sixfold", "advanced_evidence"]
assert payload["advanced_layer"]["source"] == "scripts/shadbala_advanced.py"
assert set(payload["advanced_layer"]["kala_vmdh"]) == set(SEVEN)
def test_birth_data_changes_the_strength_versus_planets_only(api_base) -> None:
status, payload = _post(api_base, {"planets": PLANETS, "ascendant": ASCENDANT, "hour": 12, "minute": 0})
assert status == 200, payload
legacy = payload["result"]
assert legacy["method_variants"]["kala"] == "legacy_approximation"
# Without the birth moment the answer is the chart page's `modules.shadbala` golden.
golden = GOLDEN["chart_modules_shadbala"]["planets"]
for planet in SEVEN:
assert legacy["planets"][planet]["total_rupas"] == pytest.approx(golden[planet]["total_rupas"], abs=1e-4)
_, precise = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert any(
precise["result"]["planets"][planet]["dig_bala"] != legacy["planets"][planet]["dig_bala"]
for planet in SEVEN
)
def test_bad_inputs_are_400_not_500(api_base) -> None:
status, _ = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT, "ayanamsa": "not-an-ayanamsa"})
assert status == 400
status, _ = _post(api_base, {**BIRTH, "planets": {"Mars": PLANETS["Mars"]}, "ascendant": ASCENDANT})
assert status == 400
def test_shadbala_service_and_api_server_have_no_undefined_names() -> None:
"""BUG-1463 was a module name used where it was never imported; ruff F821 sees that statically."""
ruff = pytest.importorskip("ruff")
targets = ["scripts/jyotish_api_server.py", "scripts/offline_compute_mixins.py", "scripts/shadbala_service"]
completed = subprocess.run(
[ruff.find_ruff_bin(), "check", "--isolated", "--no-cache", "--select", "F821", *targets],
cwd=ROOT, capture_output=True, text=True, check=False,
)
assert completed.returncode == 0, completed.stdout + completed.stderr