Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1b4be71d | ||
|
|
8f7814846b | ||
|
|
eb5e9adcf9 | ||
|
|
7ff14329e9 | ||
|
|
2f6d47c74e | ||
|
|
618d074c94 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user