Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29cf4584f6 | ||
|
|
230a493fcd | ||
|
|
bc4a6c2b49 | ||
|
|
87765d602c | ||
|
|
c881dfb4ae | ||
|
|
6721d320d8 | ||
|
|
4ab81821cd | ||
|
|
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,5 +1,34 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-02 — 报告生成中途进程重启,失败后点数会退回
|
||||
|
||||
- 报告任务最后一次尝试时,如果进程重启或卡住、租约过期,报告会被标为失败。以前这种情况不退点,只有删掉这份报告才退。现在回收时同时退点,个人报告、合盘、年运都一样。
|
||||
- 退点只退一次:回收、报告任务自己的失败处理、删除报告三处用的是同一个退点记录,重复调用不会多退。已经交付的报告不退。
|
||||
- 退点请求本身失败时不再忽略:
|
||||
- 报告:任务先不标失败,写一条 `errorCode=report_refund_failed` 的日志,等租约过期后由回收重试;最后一次尝试时,回收在同一个事务里退点并标失败,退点不成功就留到下一轮。
|
||||
- 生时校正:同一个退点请求再试一次,还不行就写 `errorCode=rectification_refund_failed` 的日志。点数留在这个校正案例上,下一轮继续用,不会再扣一次。
|
||||
- 数据库迁移 `20261102010000_report_lease_expiry_release.sql`,只替换回收函数,不改表。
|
||||
- Skill 版本不变(BUG-1505)。
|
||||
|
||||
## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地
|
||||
|
||||
- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。
|
||||
- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。
|
||||
- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。
|
||||
- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。
|
||||
- 记为 BUG-1506。Skill 版本不变。
|
||||
|
||||
## 2026-11-02 — VedAstro 卡住或断线时,报告不再失败
|
||||
|
||||
- 生成报告时,VedAstro 最多等 25 秒(从开始计算时算起,和本地计算同时进行),前端的等待是 90 秒。等不到就照常出报告,报告里的 VedAstro 一栏标「官方外部引擎本次未验证或不可用」,不会写成已验证。
|
||||
- 这次失败会记到当天:同一张盘当天再生成报告、或者同一份报告的下一个主题,直接用这条记录,不再等 VedAstro。之后 VedAstro 恢复并返回结果,就改用新结果。
|
||||
- 报告不再先跑一遍 VedAstro 主入口概览(按主题的区间扫描)。官方 VedAstro 证据仍然有,来自官方快照,按时返回就照常写进报告。
|
||||
- VedAstro 返回到一半连接断开(读响应时连接被重置或截断)时,按网络错误处理:报告照常出、VedAstro 标未验证;`/api/chart` 不带跳过概览的参数时也返回 200,不再报 500。
|
||||
- 对话、星盘页的行为不变。
|
||||
- 可调参数:`JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS`(默认 25,最多 45)。
|
||||
- Skill 版本不变(Gitea #119 第 42 项缺口 2、3;BUG-1508、BUG-1504)。
|
||||
|
||||
|
||||
## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节
|
||||
|
||||
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+109
-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,103 @@
|
||||
- 相关记录:BUG-149(门禁里 npm ci 没有安装期限)。
|
||||
- 复发自:无
|
||||
- 修复版本:staging 集成分支 `codex/chart-base-layer-label`。
|
||||
|
||||
## BUG-1504 | VedAstro 响应读到一半连接断开时,报告生成与不带 skip 的排盘返回 500
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:同步调 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`;`urllib` 只包装发请求时的错误,读响应头和响应体时的错误原样抛出。`_attach_vedastro_main_entry_overview` 外面也没有 try,所以异常一直冒到请求总入口。
|
||||
- 修复:
|
||||
- 适配器的 `_post_json` 把读响应时的 `http.client.HTTPException`(含 `IncompleteRead`、`RemoteDisconnected`)、`ConnectionError`(含 `ConnectionResetError`)和 `ssl.SSLError` 转成 `URLError("response_read_failed:<类型名>")`。所有调用点本来就把 `URLError` 记成 `network_error`,所以快照分节、区间扫描、外部技法证据都按网络错误处理,官方快照不再是 ok,网关结果是 `official_blocked`。前台和报告会话照原样把这个失败记进当天的失败缓存。读超时仍是 `timeout`,5xx 仍是 `http_error:<code>`。`RemoteDisconnected` 的标签从 `network_error:<原文>` 变成 `network_error:response_read_failed:RemoteDisconnected`。
|
||||
- `_attach_vedastro_main_entry_overview` 外面包一层 try:任何异常都记成 `status=blocked`、`reason=vedastro_main_entry_overview_error`、`error_type=<类型名>`,排盘照常返回。
|
||||
- `vedastro_rest_bridge.probe_calculate_health` 也接住 `http.client.HTTPException`,健康探测读到一半断开时记 `official_blocked`。
|
||||
- 验证:`tests/test_vedastro_report_deadline_20261102.py`:把 `urlopen` 换成 stub,分别在读响应体时抛 `IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`,在打开连接时抛 `ConnectionResetError`、`RemoteDisconnected`。`_post_json` 抛 `URLError`;官方快照分节是 `network_error:response_read_failed:<类型名>`,没有 ok;区间扫描是 `network_error`。`/api/chart`(不带 skip)走真实 `do_POST` 返回 200,`vedastro_range_scan_result` 不是 ok;编排器直接抛 `IncompleteRead` 时也返回 200,概览为 blocked。503 仍是 `http_error:503`,读超时仍是 `timeout`。把适配器和 REST 桥换回修复前版本,这几条测试失败。
|
||||
- 防复发:该测试文件已加进 `scripts/run_quality_gate.py` 的 quick 清单。新增的 VedAstro 网络调用应当走 `_post_json`,不要自己调 `urlopen`。
|
||||
- 相关记录:BUG-1508(同一轮修复,报告等 VedAstro 的总时限);Gitea #119 第 42 项;`docs/operations/outage-behaviour-2026-11-01.md`。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/vedastro-deadline-1504`。
|
||||
|
||||
## BUG-1505 | 报告任务最后一次尝试租约过期时,报告标为失败但点数不退
|
||||
|
||||
- 状态:resolved(迁移 + 回归测试;真库测试待 staging 门禁复核)
|
||||
- 首次发现 / 最近更新:2026-11-01 / 2026-11-02(Gitea #119 第 42 项,外部依赖故障审计;产品负责人 2026-11-02 同意修复)
|
||||
- 影响面:个人报告、合盘、年运等走 `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`)。
|
||||
- 根因(读码 + 本机 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` 的失败分支不检查 `release` 的结果,退点失败照样把任务关成 failed,之后再没有任何流程会去退。三种报告预扣时都用报告自己的 `request_id`,所以缺的只是回收这一步。
|
||||
- 修复:
|
||||
- 新迁移 `20261102010000_report_lease_expiry_release.sql` 替换回收函数(签名、返回值、invoker 模式和只授权 service_role 都不变,`search_path` 改为空)。要标失败的行(报告已失败,或次数用完),先按报告的 `request_id` 调 `release_usage`,成功(或 `request_completed` 已扣、`profile_missing` 钱包已不在)才标失败;退点抛错或返回其他错误时,这一行保持 running,写一条 `errorCode=report_refund_failed` 的 warning,下一轮回收再试,不影响同一批的其他行。`release_usage` 按 `request_id` 幂等,和 worker 自己的失败处理、删除触发器之间最多退一次。
|
||||
- worker:失败时先退点,退点失败(返回 false 或抛错)就不关任务,记 `[agent-observability]` 日志 `errorCode=report_refund_failed`,结果为 `refund_deferred`,留给租约过期后的回收(还有次数就重试,最后一次就退点并标失败)。报告已在别处标失败时也先退点再关任务;重试次数用完的分支走同一条退点路径。生产端口把 `request_completed`、`profile_missing` 视为已结清,和删除触发器一致。
|
||||
- 生时校正:失败或不结算时的退点,用同一个案例请求号再试一次,还失败就记 `errorCode=rectification_refund_failed`。预扣留在案例上,下一轮校正复用,不会重复扣。
|
||||
- 验证:
|
||||
- 新增 `frontend/tests/database-report-lease-expiry-release-20261102.test.ts`(真库,本机没有 Docker,跳过,由 staging 门禁跑):个人报告和年运报告最后一次租约过期后各退 2 点、只有一条退款记录;还有次数的任务只转重试、不退;再回收一次、晚到的 worker 退点、删除报告都不会多退;某一行退点被模拟故障时它保持 running,同批另一行照常退点,故障解除后下一轮补退;已交付的报告不退。
|
||||
- 新增 `frontend/tests/report-lease-expiry-release-20261102.test.ts` 锁住迁移源文本(invoker、空 `search_path`、只授权 service_role、先退后改、逐行捕获、`release_usage` 的 EXECUTE 没被收回)和两条日志能通过观测 schema。
|
||||
- `frontend/tests/personal-report-worker.test.ts` 新增 5 项(退点返回 false / 抛错时保留租约并记日志、最后一次尝试交给回收、已失败报告先退点、生产端口的结清口径),`frontend/tests/rectification-v9-agent.test.ts` 新增 3 项(重试后成功不记日志、一直失败和抛错各记一条)。
|
||||
- 迁移用 pglast 解析过全部 5 条语句和 1 个 PL/pgSQL 函数体,`tsc` 无错误。
|
||||
- 防复发:上述测试。规则:任何把报告任务关成失败的路径,都要先退点成功再关;退点失败只能留给回收,不能吞掉。
|
||||
- 相关记录:BUG-1461(解读失败不让报告失败);`20260925040000_deleted_report_release.sql`;`docs/operations/outage-behaviour-2026-11-01.md`。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/report-refund-1505`(基于 staging `ba1b4be7`)。
|
||||
|
||||
## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:所有用 `BirthPlacePicker` 的表单:星盘档案和星盘页的「编辑人物 / 编辑我的资料 / 添加一个人」(共用 `ChartProfileForm`),以及建档的出生地点卡。修复时又确认了同一根因的两条触发路径:编辑时改省份、改市或改国家(还没选完那一刻就清空并跳回空选择器,要再选一遍),以及打开海外人物的编辑(地区列表还在加载时就被清空、跳回中国)。
|
||||
- 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。
|
||||
- 触发条件:编辑已有地点的人物时,时区解析失败;或改到一半的省 / 市 / 国家;或打开海外人物。
|
||||
- 根因(读码 + 真实渲染确认):选择器在两处向上报 `null`:时区查询失败(`frontend/src/components/birth-place-picker.tsx` 原 274 行)和选项没选完(原 228-233 行)。`frontend/src/components/birth-location-fields.tsx` 收到 `null` 就把地点代码、坐标和时区全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(原 129-132 行),刚设置的错误状态也一起被丢掉。另外改日期查到新时区后再改回原日期,旧代码按「挂载时的出生时刻」判断已存时区有效,会把新日期的时区当原日期的存下去。
|
||||
- 修复:
|
||||
- 选择器不再上报 `null`,`onChange` 只在查到新时区时调用;失败和没选完都保留资料里原来的地点和时区。
|
||||
- 新增 `onPendingChange`:已选地点或出生时刻需要新时区、但资料里还没有(没选完、正在查、查失败)时为 true。`frontend/src/components/birth-place-pending.tsx` 的 `BirthPlacePendingScope` 收这个状态,`BirthPlaceSubmitButton` 据此禁用保存。`ChartProfileForm` 和建档出生地点卡都包了这一层,所以「保存」/「确定」在时区查到或用户改回原值之前不能点。
|
||||
- 查失败时,有原地点的写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,后面是「重试」;新建(没有原地点)仍是「时区服务暂时不可用,稍后重试即可。」加「重试」。不加别的提示行,状态行位置和高度不变。
|
||||
- 资料里时区对应的出生时刻单独记(`valueMoment`),查到新时区时更新;改回原日期时会重新查,不再把别的日期的时区存下去。查询被取消后迟到的成功结果不再写回。
|
||||
- 验证:新增 `frontend/tests/birth-place-timezone-failure-20261102.test.tsx`,用真实 React 渲染 `ChartProfileForm` + fetch stub:改日期后时区 503 → 地点、坐标、时区原样保留,显示上面的错误句和「重试」,保存按钮禁用;重试成功 → 错误消失、时区改为新值、保存可点;改回原时间 → 不再请求,恢复原时区;改省份没选完 → 草稿留在新省份、资料不变、保存禁用;新建人物查失败 → 新建文案 + 重试,资料里没有可用地点;海外人物打开时(地区列表成功或失败)资料都不被清空。把 `frontend/src` 换回修复前版本,这 6 条行为测试全部失败。`birth-place-picker.test.ts` 的源码断言按新写法改,带原值 / 新值 / 原因注释。
|
||||
- 防复发:上面的行为测试。表单里放出生地选择器时要包 `BirthPlacePendingScope` 并用 `BirthPlaceSubmitButton`,同一测试文件检查两处表单的接线。选择器的 `onChange` 类型不再接受 `null`,以后谁再想用「上报空值」来表示失败会过不了类型检查。
|
||||
- 遗留:海外人物地区列表加载失败时,再改日期会保存不了,但状态行只说「该国家的地区列表暂时不可用」,没有重试(`docs/operations/outage-behaviour-2026-11-01.md` 第二节另一行,未改)。时区接口卡住时仍没有超时(同节另一行,未改)。
|
||||
- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节;BUG-1507。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/profile-birthplace-1506`(基于 `origin/staging` ba1b4be7)。
|
||||
|
||||
## 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。
|
||||
- 复发自:无
|
||||
- 修复版本:未修。
|
||||
|
||||
## BUG-1508 | 报告生成同步等 VedAstro,没有总时限,VedAstro 卡住时报告超时失败
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计,缺口 2)
|
||||
- 最近更新:2026-11-02
|
||||
- 影响面:个人报告生成(`/api/reports` 创建和报告 worker 调 `/api/consultation_workflow`)。对话、星盘页不受影响。
|
||||
- 用户现象:VedAstro 卡住时报告以 `workflow_timeout` 失败(会退点)。期间一直占着进程级两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。下一份报告还要再等一遍。
|
||||
- 触发条件:VedAstro 不响应(每次请求都等满 `VEDASTRO_TIMEOUT_SECONDS`,生产 20 秒)时生成报告。
|
||||
- 根因(读码 + 本机 stub 确认):报告调用没有传 `foreground`,所以 `defer_optional_external_evidence=false`,Python 端同步跑两段 VedAstro:排盘里的主入口概览(约 4 × 20 秒)和生时门禁里的官方快照(含 8 秒健康探测,约 48 秒)。合计约 128 秒,超过前端 90 秒(`frontend/src/mastra/consultation-workflow.ts:149`)。这条路径的结果直接取自门禁,不经过前台会话,也就不写失败缓存。
|
||||
- 修复:复用对话已有的前台会话、后台线程和当天失败缓存(#130),不另起一套。
|
||||
- 前端:报告的两处调用(`frontend/src/app/api/reports/route.ts`、`frontend/src/lib/personal-report-worker.ts`)传 `vedastroEvidenceBudget: "report"`,请求体带 `vedastro_evidence_budget: "report"`。报告仍不传 `foreground`。
|
||||
- Python:`scripts/vedastro_foreground.py` 新增报告档。带这个字段的请求按 defer 处理,和对话一样:不跑同步的主入口概览;生时门禁只读缓存;官方网关在后台线程里跑,和本地计算并行。不同的是等待时长:对话从收尾时起最多等 1.5 秒;报告从会话开始起算,最多等 25 秒(`JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS`,上限 45 秒)。单次请求超时沿用适配器的配置值,报告不会把它调高,也不超过 2 倍时限。
|
||||
- 到时限网关还在跑:报告照常继续,VedAstro 记 `official_blocked` / `report_vedastro_deadline_exceeded`,并写进这张盘当天(UTC)的失败缓存,下一份报告和下一个主题直接用它,不再等。后台线程继续跑:之后成功就写进正常快照缓存(查缓存时成功优先),失败就用真实失败覆盖这条记录。还没开始就被取消的任务不记。
|
||||
- 5xx、连接截断等很快返回的失败照旧记进失败缓存。
|
||||
- 报告里的证据如实标出:`machine_evidence_packet` 的 `external_oracle_status` 是 `official_blocked`,`vedastro_official_raw_response` 是 `missing`,报告的技法审计表按原有规则显示 blocked(「官方外部引擎本次未验证或不可用」)。对话技法审计的说明文字加了 `report_vedastro_deadline_exceeded` 的中文。
|
||||
- 取舍:报告不再跑主入口概览(按主题的区间扫描),引导话题里的 VedAstro 区间事件显示为没有;官方 VedAstro 证据改由网关的官方快照提供,按时返回就照常进报告。
|
||||
- 验证:`tests/test_vedastro_report_deadline_20261102.py`,VedAstro 全部是 stub:网关卡住、时限 0.4 秒时,会话 0.4 秒左右返回 blocked,失败写进缓存,第二次 0.15 秒内返回、网关只调过一次;时限从会话开始起算,本地计算占掉的时间不再额外等;到时限后网关成功,下一次读到成功快照;很快返回的 503 类失败和抛 `IncompleteRead`、`ConnectionResetError` 的网关都记成 blocked 并写缓存;健康时照常用网关结果;对话档的 1.5 秒超时仍不记;单次请求超时沿用配置值。整条 `consultation_workflow` 带报告字段时不调主入口概览,证据段落为 `official_blocked` / `missing`,第二次不再调网关。本机脚本把时限设为 6 秒、单次超时 20 秒、VedAstro 全部卡住:第一份 6.23 秒,第二份 0.11 秒(修复前同样条件约 128 秒)。脚本是临时的,没有入库。
|
||||
- 防复发:该测试文件已加进 `scripts/run_quality_gate.py` 的 quick 清单;`frontend/tests/consultation-workflow-contract.test.ts` 锁住报告两处调用都带 `vedastroEvidenceBudget: "report"`、都不带 `foreground: true`。
|
||||
- 相关记录:BUG-1504;BUG-161、BUG-727、#130(前台会话和失败缓存);`docs/operations/outage-behaviour-2026-11-01.md` 第一节、第五节第 2 条。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/vedastro-deadline-1504`。
|
||||
|
||||
@@ -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。**2026-11-02 已修**:保留原地点,显示「时区这会儿没查到,原来的出生地还在,查到后才能保存。」加「重试」,保存按钮在查到前不能点 |
|
||||
| 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `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 失败也被忽略。**2026-11-02 已修**:租约回收在判失败前先退点(与删除触发器、worker 同一个请求号,不会重复退);退点失败的任务留到下次回收重试,并记 `errorCode=report_refund_failed`。
|
||||
2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。**2026-11-02 已修(BUG-1508)**:报告复用对话的前台会话和失败缓存,VedAstro 最多等 25 秒,到时限标 blocked 并记当天。
|
||||
3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。**2026-11-02 已修**:读响应时的截断和重置按网络错误处理,主入口概览出错时排盘照常返回。
|
||||
4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**2026-11-02 已修。
|
||||
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` 三行。 |
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# 输入框预设提示词 · 按占星师审阅意见合成的定稿候选(issue #151,2026-11-02)
|
||||
|
||||
来源:初稿(10-28,48 条)+占星师审阅版 `输入框预设提示词-占星审阅版-151(1).docx`(jesse 11-02 上传)。
|
||||
本稿逐条采用审阅意见:标「修改」的按意见改写;标「删原条,换新条」的 2 条直接用审阅给的替代提示词;每个领域加审阅补充的 1 条候选问题。共 12 个领域、60 条。
|
||||
|
||||
**本稿只是菜单文案,不是网站接入验收。** 审阅中的「统一执行要求」(先确认出生资料与口径、逐项记录 executed / blocked / not_applicable、MD/AD/PD 日期只能来自引擎、MEVG 等)属于后台规则,不塞进用户输入框,另行核对现有咨询流程是否已满足。
|
||||
|
||||
写法约定(来自审阅):
|
||||
- 结构:先直接回答,再说依据和不同看法,最后给一件能做的事。
|
||||
- 每条都要求说明「出自盘上哪里」,并分开写明显的倾向和只是有可能的。
|
||||
- 不按栏目预先锁定「年 / 月 / 旬」;交运、换座等计算日期可以精确,但不等于事件必在那天发生。
|
||||
- 括号 `(……)` 是让用户自己填的地方,点菜单后光标停在第一个括号处。
|
||||
|
||||
---
|
||||
|
||||
## 1 认识自己
|
||||
|
||||
**1.1 盘面提示我擅长什么、容易卡在哪里?** —— 先看清自己的底子
|
||||
> 请只从我的本命盘看盘面提示的长处和容易卡住的地方,不谈具体哪一年会发生什么。先直接说结论,再说明依据:上升、月亮、太阳、上升主星和相关宫主各自说明了什么,并结合实际强度;功能吉凶按我的上升判定,不要把某颗星简单说成帮助或伤害。每个判断说明出自盘上哪里,分开写明显的倾向和只是有可能的。最后给一件现在就能做的小事。
|
||||
|
||||
**1.2 我为什么总在同一类事上反复?** —— 找出重复出现的模式
|
||||
> 我反复遇到的一件具体的事是:(请写下)。请从本命盘中找出可供核验的对应结构(相关宫主、相位、相关分盘和运行期),不用逐宫讲解,也不要从象征倒推这件事的真实原因。先直接回答,再说明依据,分开写盘上确实有的和推测的。最后说我可以怎样认出它正在发生。
|
||||
|
||||
**1.3 哪种生活节奏可以试试?** —— 做事、休息、和人相处的方式
|
||||
> 我平时的作息大致是:(可选填写)。请根据我的本命盘,说说我可以试验的做事节奏、休息方式和相处距离。只谈可以试的长期偏好,不规定睡眠时长或运动种类,也不评价社交能力。每点说明依据,注明明显的和只是可能的。最后给三条可以从下周开始试的调整,并说明怎样根据实际感受修正。
|
||||
|
||||
**1.4 我现在所处的阶段有哪些重点?** —— 把当下放进更长的人生阶段里
|
||||
> 请先读取我当前 Vimshottari 大运、小运的真实起止日期和 Narayana Dasha 当前所在时段,再说这几年的重点。只谈阶段重点,不预测具体事件,也不把挫折说成必须承受的命定功课。两套运程一致和不一致的地方分开写。先给结论再给依据,最后说这个阶段最值得花力气的一件事。
|
||||
|
||||
**1.5(新增)出生时间不准时我还能看什么?** —— 先用稳定的部分
|
||||
> 请根据我已提供的出生时间来源和可能范围,说明哪些结果稳定、哪些会随时间变化,先给仍可用的分析;如需校时,说明需要哪些有日期的经历,不承诺校正到某一分钟。
|
||||
|
||||
## 2 感情与婚姻
|
||||
|
||||
**2.1 我在感情里是什么样的人?** —— 我怎么爱人、需要什么
|
||||
> 请只看我在亲密关系里的长期模式:怎么表达感情、需要什么、容易在哪里受伤,不预测什么时候结婚。以 D1 为基础:5 宫和 5 宫主看恋爱与情感表达,7 宫和 7 宫主看配对与长期关系,再看月亮和金星;D9 只作长期关系议题的辅助。先给结论再说明依据,分开写明显的倾向和只是可能。最后用三四句话总结。
|
||||
|
||||
**2.2 我为什么总遇到类似的关系问题?** —— 先说清是哪一种关系
|
||||
> 我在(恋爱 / 已确立的关系 / 婚姻,请选一种)里反复遇到:(简单描述)。请按我选的阶段找出一两个可供核验的结构:恋爱看 5 宫及宫主;确立关系看 7 宫及宫主并参考 A7;婚姻再加 UL 和 D9。只看长期结构,不判断某一段关系的对错,也不用婚姻结构解释所有关系问题。说明依据,分开写明显的和只是可能的。最后说下次遇到时可以怎么做。
|
||||
|
||||
**2.3 我和什么样的人更容易长久相处?** —— 不是理想型,是合得来的类型
|
||||
> 请以 D1 为基础,交叉 D9、DK、7 宫和 7 宫主,说说什么样的伴侣和我更容易长久相处:性格、生活方式、价值观。这只是从我本人盘推测的相处模式,不是对某个人的事实确认,也不说这种人一定和我长久;不描述外貌,不说对方何时出现。每点说明依据,最后说我自己这边最需要留意的一点。
|
||||
|
||||
**2.4 这几年感情上要注意什么?** —— 先选你关心的是哪一步
|
||||
> 我关心的是:(心动 / 相识 / 确立关系 / 法律婚姻,请选一种)。请按这个目标调用相应宫位和派生点,结合我当前的 Vimshottari 大运和小运、Narayana Dasha,以及土星和木星的行运,给出未来两三年有依据的候选阶段。每个阶段分别写支持、反证,以及我可以用现实情况核验的问题;不把恋爱活跃写成必然结婚,两套运程不一致时直接说。最后用三四句话总结。
|
||||
|
||||
**2.5(新增)我更想知道相识还是确立关系的时机** —— 把事件分清再看
|
||||
> 请先帮我区分相识、心动、确立关系和法律婚姻;按我选定的目标,结合本人盘给出候选阶段、支持与反证。只描述可核验的相遇或相处模式,不把未知对象的职业、收入、婚史写成事实。
|
||||
|
||||
## 3 事业与工作
|
||||
|
||||
**3.1 我适合什么样的工作?** —— 不是具体职位,是方向和方式
|
||||
> 我的技能和资历大致是:(可选填写)。请结合 D1 的 2、6、7、10、11 宫(收入、受雇、合作、职业、收益)及各宫主,以及 D10、A10(注明所在盘)和 AmK,说说我适合的工作方向和做事方式(独立还是协作、稳定还是变化)。不替我选具体职位或公司,也不凭星盘排除某个职业。先给结论,每点说明依据,分开写明显的倾向和只是可能。最后用三四句话总结。
|
||||
|
||||
**3.2 我现在该换工作吗?** —— 先说清是哪一种变动
|
||||
> 我在考虑:(换公司 / 转行 / 内部调岗 / 失业后求职,请选一种)。请看当前和接下来一两年:D1 的 2、6、7、10、11 宫及宫主,D10 与 A10,Vimshottari 大运和小运、Narayana Dasha,以及土星、木星对相关宫位和宫主的行运与相位(也参照月亮)。分别写出留下和离开各自需要的条件,两套运程不一致时直接说。不替我做决定。最后列出做决定前该想清的三件事。
|
||||
|
||||
**3.3 我在工作里最容易吃亏的地方是什么?** —— 和上级、同事、合作者
|
||||
> 请从我的盘看工作中容易吃亏的一两个地方:对上级看太阳和 9、10 宫,对合作方看 7 宫,对同事和服务关系看 6 宫,团队收益看 11 宫;自然吉凶、功能角色和实际强度一起判断,不要只挑功能凶星找负面解释。说明依据,不当成对我的否定。最后给两条具体的应对办法。
|
||||
|
||||
**3.4 未来十年事业有哪些阶段变化?** —— 长期规划,先宽后细
|
||||
> 请用 Vimshottari 和 Narayana Dasha 两套运程,给出未来十年事业上的长期规划视图:远期先给宽区间,近期在数据支持时再细化。区分升职、转岗、创业和中断,不把变化一律当成上升,也不硬找「最明显」的上升期。两套运程都指向的和只有一套指向的分开写。最后用三四句话总结。
|
||||
|
||||
**3.5(新增)我该争取晋升还是考虑转岗?** —— 两条路分开看
|
||||
> 我现在的岗位和目标是:(填写)。请分别讨论争取晋升和转岗的条件性机会、阻力与阶段,用 D1、D10、A10、AmK 和相关运期说明依据,并列出做现实决策前需要核对的信息。
|
||||
|
||||
## 4 钱与财富
|
||||
|
||||
**4.1 我靠什么赚钱最顺?** —— 主业、手艺、合作还是积累
|
||||
> 请结合 D1 的 2、5、9、11 宫,加上 10、6 宫(工作收入)和 7 宫(合作)及各宫主,以及 D2,说说我更顺的赚钱方式:工资、手艺、合作还是长期积累。D11 只在注明所用算法和传承后作条件性补充;财富 Yoga 要区分是否成格、强度和触发。这不是投资建议,不推荐买什么。先给结论,每点说明依据,分开写明显的倾向和只是可能。最后用三四句话总结。
|
||||
|
||||
**4.2 我的钱为什么留不住?** —— 先看实际收支
|
||||
> 我的实际情况是:(固定支出、责任、借贷等,可选填写)。请先结合这些现实情况,再用 2、11、12 宫及宫主做条件性的主题解释;不用八分法分数推断花钱性格或损失概率,不判断某一笔收支。说明依据。最后给三条适合我的理财习惯(不涉及具体产品)。
|
||||
|
||||
**4.3 哪些阶段需要多留财务余地?** —— 提前留出余地
|
||||
> 请用 Vimshottari 和 Narayana Dasha,结合 2 宫(个人资金)、11 宫(收益)、12 宫(支出)、6 宫(负担)、8 宫(共同资源)及各宫主和相关行运,看未来五年哪些阶段更需要多留余地。较高的支出也可能来自教育、搬家或投入,不直接说成破财,也不说一定会发生。每个阶段写依据,两套运程不一致时直接说。最后说这些阶段前可以做的准备。
|
||||
|
||||
**4.4 合伙或借钱给人,对我是加分还是风险?** —— 合作和借贷分开看
|
||||
> 我想问的是:(经营合作 / 私人借贷,请选一种)。请分开看 2 宫(个人资金)、6 宫(债务与争议)、12 宫(支出),以及 7 宫(合作)、8 宫(共同资源)、11 宫(收益)。不据本命盘判断某个人可不可信,也不评价具体项目;现实合同和账目需要另行核验。这不是投资建议。说明依据。最后给两条合作前该先谈清的事。
|
||||
|
||||
**4.5(新增)我怎样安排主业和副业更合适?** —— 能力、渠道和现金流分开
|
||||
> 我的技能、可用时间和收入责任是:(填写)。请分别看主业与副业的候选发展模式及阶段,区分工作能力、收入渠道和现金流压力;不保证收入,不推荐投资或借贷产品。
|
||||
|
||||
## 5 家庭
|
||||
|
||||
**5.1 我在家里通常是什么角色?** —— 承担、照顾还是被照顾
|
||||
> 请先从 2、4 宫及宫主和月亮看我在家庭里的整体角色和容易落到我身上的责任;涉及父母和原生家庭时补 D12,涉及婚后家庭补 7 宫,涉及育儿补 5 宫,不把不同亲属混成一个判断。只看长期模式,不评价哪个家人对错。每点说明依据。最后用三四句话总结。
|
||||
|
||||
**5.2 家里的事和我自己的生活,边界怎么划?** —— 先说清实际承担了什么
|
||||
> 我实际承担的是:(照顾任务、经济承担、沟通上的难处等,请简单写)。请按这些情况,从 2、4 宫及相关关系宫分析我更容易在哪里多付出、在哪里需要给自己留空间,不从 12 宫直接推出「过度付出」。说明依据,分开写明显的和只是可能的。最后给两条可以试的说法或做法——这是建议,不是星盘决定的边界。
|
||||
|
||||
**5.3 这几年家里要留意什么?** —— 先选是哪一种变动
|
||||
> 我想看的是:(搬家 / 照护 / 家庭支出 / 关系调整,请选一种)。请按这个主题加入 D4、D12 或 2、5、7 宫,结合当前的 Vimshottari 大运和小运、Narayana Dasha,以及土星和木星的行运,看未来两三年哪些阶段责任可能更重。只说到阶段,不从 4 宫行运推断家人会发生什么具体事件,也不预测家人的健康或生死。说明依据。最后用三四句话总结。
|
||||
|
||||
**5.4 我的原生家庭对我有什么影响?** —— 从小带出来的东西
|
||||
> 请从 2、4、9 宫及宫主、太阳、月亮和 D12 看,我从原生家庭感受到的优势和负担,以及它们在现在生活里怎么表现。只谈我这边感受到的模式,不认定父母曾有某种行为、创伤或秘密,也不评价父母。每点说明依据,并请我核对哪些描述符合我的经历。最后说一件我可以主动放下或保留的东西。
|
||||
|
||||
**5.5(新增)小家庭和原生家庭的责任怎样平衡?** —— 找一个可以谈的边界
|
||||
> 我实际承担的责任是:(填写)。请从本人盘讨论两类关系中需要核验的互动模式和阶段,不裁定家人谁对谁错,最后给一个可以谈的具体边界。
|
||||
|
||||
## 6 父母
|
||||
|
||||
**6.1 我和父亲的关系该怎么理解?** —— 支持、期待与距离
|
||||
> 请从 9 宫和 9 宫主、太阳和 D12 看我和父亲关系里最主要的课题,并注明这是所采用传承的看法,不是唯一用法。只看我这边的感受和模式,不推断父亲的私事或健康,也不据我的盘确认关于他的事实。每点说明依据。最后给一条和他相处时可以试的做法。
|
||||
|
||||
**6.2 我和母亲的关系该怎么理解?** —— 照顾、牵挂与边界
|
||||
> 我们现在的关系背景是:(可选填写)。请从 4 宫和 4 宫主、月亮和 D12 看我和母亲关系里最主要的课题。只看我这边的感受和模式,不推断母亲的私事或健康;不把月亮受克写成母亲不好、缺爱或有病。每点说明依据。最后给一条和她相处时可以试的做法。
|
||||
|
||||
**6.3 父母年纪大了,我该怎么安排自己的精力?** —— 照顾父母和自己的生活
|
||||
> 我目前的照护安排和能得到的支持是:(填写)。请结合当前大运和 4、9、12 宫,讨论这几年我在照顾父母上的阶段性责任主题,怎样安排才不至于把自己耗尽。不量化我能承担多少,也不从我的盘判断父母何时需要护理,不预测父母的健康和寿命。说明依据。最后给三条具体建议。
|
||||
|
||||
**6.4 我和父母的分歧卡在哪里?** —— 从一次典型分歧说起
|
||||
> 我和父母一次典型的分歧是:(简单描述)。请从 4、9 宫、太阳和月亮,加上水星、2、3 宫及相关宫主看表达方式,D12 辅助,说说这种分歧可能来自哪里;价值观冲突不只靠太阳和月亮判断。只看我这边,不评价父母。说明依据。最后给两条沟通时可以换的说法。
|
||||
|
||||
**6.5(新增)我怎样与父母保持联系又保留空间?** —— 情感支持和现实照护分开
|
||||
> 我和父母现在的联系方式、距离和照顾安排是:(填写)。请讨论我这边的关系模式,区分情感支持和现实照护,不据星盘判断父母的需求或健康事实。
|
||||
|
||||
## 7 孩子
|
||||
|
||||
**7.1 我在亲子关系中有哪些倾向?** —— 亲子关系的底色
|
||||
> 请从 5 宫和 5 宫主、木星、D7 和 PK(注明用的是七星还是八星 Karaka 排序)看我在亲子关系里的倾向与相处底色。不预测会不会有孩子、有几个、性别或时间,也不对孩子的人生下定论。每点说明依据,分开写明显的倾向和只是可能。最后用三四句话总结。
|
||||
|
||||
**7.2 我是什么样的父母?** —— 我带孩子的优势和盲点
|
||||
> 我现在:(已经在带孩子 / 在考虑未来养育,请选一种)。请从 5 宫和 5 宫主、木星、月亮和 D7 看我在养育上的候选习惯:优势和容易忽略的地方,并给几个我可以核验的问题;这不是对我教养能力的评估。最后给三条具体的陪伴建议。
|
||||
|
||||
**7.3 孩子的学业和成长,我该怎么陪?** —— 从我这边能给什么
|
||||
> 孩子现在的年龄、意愿和实际困难是:(填写)。请从我的 5 宫和当前大运看,这几年我在陪伴上的投入和期待更适合放在哪里。只看我这边,不推断孩子的成绩,也不给孩子贴能力标签。说明依据。最后给两条具体做法。
|
||||
|
||||
**7.4 孩子大了,我和 TA 的关系怎么调整?** —— 从管到放
|
||||
> 请以 5 宫及宫主、D7 和我本人的运行期为主,看我和已经长大的孩子之间,哪些地方容易管得太多、哪些地方可以放手;如果用到从子女宫推导的衍生宫位,说明推导对象和理由。只看我这边的模式,不评价孩子。说明依据。最后用三四句话总结。
|
||||
|
||||
**7.5(新增)我怎样减少把自己的期待放在孩子身上?** —— 只看我这边的期待
|
||||
> 孩子的年龄和我正在担心的事是:(填写)。请只分析我在养育中的期待和控制倾向,提出几个核验问题和一种陪伴上的调整,不预测孩子的成绩、职业或命运。
|
||||
|
||||
## 8 学习与考试
|
||||
|
||||
**8.1 我怎么学东西最有效?** —— 找到可以试的学法
|
||||
> 我平时学习的实际情况是:(可选填写)。请以 4 宫(基础教育)、5 宫(理解与记忆)、9 宫(高等学习)及各宫主为主,D24、水星、木星辅助,看我可以试试的学习方式(自学还是有人带、先理论还是先动手)。不把某种学习风格写成唯一适合我的。每点说明依据,分开写明显的和只是可能的。最后给三条可以马上用的学习习惯。
|
||||
|
||||
**8.2 我还要不要继续深造?** —— 先选是哪一种
|
||||
> 我在考虑:(考研 / 留学 / 转行学新东西,请选一种)。请用 D24 和 4、5、9 宫及宫主;留学再加迁移相关的层,转行再加 D10;结合当前的 Vimshottari 与 Narayana Dasha,看这几年做这件事的投入与阻力。不判断能不能考上,也不替代录取条件、预算和职业回报的评估,不替我做决定。最后列出决定前该想清的三件事。
|
||||
|
||||
**8.3 我在学习上容易卡在哪里?** —— 只作待核验的线索
|
||||
> 请从水星、月亮、5 宫,加上 3 宫(努力)、4 宫(学习环境)及相关宫主,看我在学习上可能卡住的一两个地方,只作待核验的线索;不从星盘诊断注意力、智力或焦虑问题,也不当成对我的否定。说明依据。最后给两条具体的破解办法。
|
||||
|
||||
**8.4 未来两三年哪些阶段适合集中学习?** —— 考证、考试、学新技能
|
||||
> 请用 D24 和 4、5、9 宫主,Vimshottari 和 Narayana Dasha,以及相关行运,看未来两三年哪些区间适合准备或调整学习计划;竞争性考试可参考 6 宫。只说到阶段,不保证成绩,也不建议因为阶段不顺就放弃考试。两套运程不一致时直接说。最后用三四句话总结。
|
||||
|
||||
**8.5(新增)我怎样选择现在最值得学的技能?** —— 比较两三种方向
|
||||
> 我的目标、已有基础和可投入的时间是:(填写)。请从学习倾向和当前阶段提出两三种可比较的方向,说明各自适用的条件;涉及转行时同时参考职业层,不保证学习回报。
|
||||
|
||||
## 9 压力与生活节奏
|
||||
|
||||
菜单上这一组顶部固定一行说明:**这一组只作自我观察和生活安排,不诊断身体或心理问题;如果已经影响睡眠或生活,请先找专业的人帮忙。**
|
||||
|
||||
**9.1 我的压力主要从哪里来?** —— 从实际情境说起
|
||||
> 让我压力大的实际情境是:(工作 / 关系 / 对自己的要求等,请简单写)。请以月亮、上升、4 宫和相关领域作自我观察的线索,只在和我说的情境有关时才用 6、8、12 宫,说说压力更容易从哪里来、通常怎样表现。这不是医学判断。每点说明依据。最后给三条日常可以做的减压办法。
|
||||
|
||||
**9.2 我的生活节奏怎么调整更合适?** —— 任务、边界和休息安排(审阅替换原条)
|
||||
> 请结合我已经描述的工作、家庭和休息安排,从本人盘中提出可供核验的生活节奏倾向;只讨论任务分配、边界和休息安排,不依据星盘推荐运动、饮食或健康方案。先说一个最值得调整的地方,再给一种低成本尝试,并说明如何用实际感受判断是否适合。
|
||||
|
||||
**9.3 这几年哪些阶段适合预留休息?** —— 提前给自己留余地
|
||||
> 请结合当前大运、小运和相关行运,按具体的生活主题看未来两三年哪些阶段责任或事务可能较集中、适合提前预留休息。不从土星行运推断身体会衰弱或生病,这不是医学判断。只说到阶段。说明依据。最后给两条可以提前做的安排。
|
||||
|
||||
**9.4 我为什么总是想太多?** —— 情绪与思虑的线索
|
||||
> 我容易反复想太多的情境是:(简单描述)。请从本人盘找出可以自我观察的线索,不把月亮、水星或 8、12 宫当成症状的原因,这不是心理诊断。说明依据,分开写明显的和只是可能的。最后给两条低风险的小办法(比如记录、调整计划);如果已经影响睡眠或生活,请建议我寻求专业支持。
|
||||
|
||||
**9.5(新增)我怎么识别自己承担太多的情境?** —— 找一件可以减少的事
|
||||
> 我的日常安排是:(填写)。请先从中找出可观察的负担,再用本人盘提出待核验的责任与边界主题,给一项可以减少或重新协商的任务,不诊断心理或身体问题。
|
||||
|
||||
## 10 居住与迁移
|
||||
|
||||
**10.1 我适合在哪类地方生活?** —— 先说清现实条件
|
||||
> 我的工作、预算、家人和生活偏好是:(可选填写)。请以 D1 的 4 宫、4 宫主、月亮和 D4 为主,涉及远近迁移再看 3、9、12 宫,说说我更适合的生活环境倾向。不直接给出城市等级或环境优劣,也不替我选具体城市。每点说明依据。最后用三四句话总结。
|
||||
|
||||
**10.2 我去外地或海外发展顺不顺?** —— 异地、出国、外派、留学
|
||||
> 我在考虑:(外地工作 / 海外发展 / 外派 / 留学,请选一种)。请以 D1 的 3、4、9、12 宫及宫主和 D4 为主,外派加 D10,留学加 D24,罗睺只作辅助;只有牵涉父母时才补看 D12。结合当前的 Vimshottari 与 Narayana Dasha 看哪些阶段更合适,两套运程不一致时直接说。不判断签证或申请结果。最后列出三件出发前该想清的事。
|
||||
|
||||
**10.3 什么时候适合搬家或买房?** —— 搬家和买房分开看
|
||||
> 我想问的是:(搬家 / 买房,请选一种)。搬家看 4 宫、D4 和迁移相关的层;买房另加 2、11 宫的资源和 6、8 宫的负担与共同资金。结合 Vimshottari 和 Narayana Dasha 与相关行运,给出未来三五年的候选阶段和反证。阶段信号不等于买房划算或贷款能承受;这不是房产或贷款建议。最后用三四句话总结。
|
||||
|
||||
**10.4 我在现在的地方为什么总住不安稳?** —— 先核对现实情况
|
||||
> 我住得不安稳的现实情况是:(噪音、合同、通勤等,可选填写)。请先核对这些事实,再从 4 宫、月亮和当前大运看我本人的安全感和迁移主题。星盘不能判定住房质量或现实安全,也不做「环境问题还是阶段问题」的二选一归因。说明依据,分开写明显的和只是可能的。最后给两条不搬家也能做的调整。
|
||||
|
||||
**10.5(新增)我更适合短期外出还是长期迁居?** —— 先说清目标和期限
|
||||
> 我的迁居目标和期限是:(填写,并说明是为了工作、学习还是家庭)。请分别分析短期流动和长期定居的条件性主题及候选阶段,不预测签证获批,不代替现实城市比较。
|
||||
|
||||
## 11 未来十二个月
|
||||
|
||||
**11.1 未来十二个月我最该专注什么?** —— 两三件最重要的事
|
||||
> 请先固定未来 12 个月的实际起止日期,以当前 Vimshottari 大运、小运为背景,细运只在条件合适时使用;按相关主题与 Narayana Dasha 交叉,结合土星和木星的行运;如果年运(Tajika)实际可用就纳入并说明状态。说说这段时间最值得专注的两三件事,只说大方向,不预测具体事件。每点说明依据。最后用三四句话总结。
|
||||
|
||||
**11.2 未来十二个月的节奏怎么分段?** —— 有差异才细分
|
||||
> 请按实际的运期变化、行运触发和主题变化,把未来 12 个月分段;只有区间确有差异时才细化到月份,允许连续几个月同一主题,不强制每个月都分好坏。同一段里事业和关系可以方向不同,不给整月打统一的吉凶分。每段写起止和依据。最后用三四句话总结。
|
||||
|
||||
**11.3 未来十二个月要特别留意什么?** —— 写成能核验的事
|
||||
> 请优先从我本命和专题盘已有的依据里,筛出未来 12 个月最该留意的一两项,再看当前大运、小运和相关行运,不只看土星。把「要留意」写成可以核验的责任、计划或沟通议题,不吓人,也不说一定会发生。说明依据。最后给两条提前可以做的准备。
|
||||
|
||||
**11.4 这一年和过去一年比,有什么不一样?** —— 对比两段一样长的时间
|
||||
> 我过去一年实际发生的事是:(可选填写)。请对比过去 12 个月和未来 12 个月(或我指定的两个自然年)所在的小运、细运和土星、木星行运,说说主题有什么转变;不要编造过去的事当作印证。只谈大方向,每点说明依据。最后说一件今年可以做得和去年不一样的事。
|
||||
|
||||
**11.5(新增)未来十二个月我的两个目标怎么排先后?** —— 支持、阻力和现实约束
|
||||
> 我的两个目标是:(填写)。请分别调用相关领域资料,比较未来 12 个月各自的支持、阻力和现实约束,再提出可调整的先后顺序;不要把一个月或一年整体归成吉或凶。
|
||||
|
||||
## 12 时机
|
||||
|
||||
**12.1 我想做的一件事,哪个阶段做更顺?** —— 先写下是什么事和期限
|
||||
> 我想做的事是:(填写),最早和最晚的期限是:(填写),已有的约束是:(可选填写)。请按这件事所属的领域选用相关技法,用 Vimshottari 和 Narayana Dasha 两套运程,结合相关行运,给出有依据的候选阶段;只有数据支持时才细到月份,两套都指向的和只有一套指向的分开写。如果我问的是具体哪天几点办理,请说明要另走择时流程。最后用三四句话总结。
|
||||
|
||||
**12.2 我的大运什么时候换、换了会怎样?** —— 下一个大阶段
|
||||
> 请写出我当前 Vimshottari 大运和下一个大运的起止日期(附计算参数),并单独列出 Narayana Dasha 下一次换座的日期,两套日历不要混用。说说换运前后的阶段主题会怎样变;交运日期是计算结果,不代表那天就会有变化。每点说明依据。最后说换运前最值得做的准备。
|
||||
|
||||
**12.3 土星和木星这几年对我有什么影响?** —— 两颗慢星的节奏
|
||||
> 请说明土星和木星未来三年在我盘上经过哪些宫、什么时候换宫,并注明是从上升还是从月亮数宫;对照相关宫主、实际相位、逆行重过和当前大运。用到 Ashtakavarga 时区分 SAV 和 BAV 及所用规则,不把分数直接换成影响大小或应验概率,也不把不同体系的分歧机械相抵。只说到阶段。最后用三四句话总结。
|
||||
|
||||
**12.4 下个月最值得关注什么?** —— 近期的小节奏(审阅替换原条)
|
||||
> 请固定下一个自然月的实际日期,结合本人本命相关主题、当前大运与小运、可用细运及相关行运,说明本月最值得关注的一两件事。有明确依据时给候选时间段,否则说明全月主题,不硬分上中下旬。把支持与反证分开,月亮行运只作辅助,最后给一个可执行的安排。
|
||||
|
||||
**12.5(新增)到底是选一段时间还是选某一天?** —— 本命阶段和择时分开
|
||||
> 我计划做:(填写),可选时间范围是:(填写),地点是:(填写)。请先判断我问的是本命阶段还是具体择时;如需择时,使用对应 Muhurta 流程并说明缺失信息,不用本命大运代替。
|
||||
|
||||
---
|
||||
|
||||
## 和初稿相比改了什么(摘要)
|
||||
|
||||
| 类别 | 内容 |
|
||||
| --- | --- |
|
||||
| 整条替换 | 9.2(原「我该怎么照顾自己的状态」)、12.4(原「下个月有什么要留意的」),均用审阅给的替代提示词 |
|
||||
| 标题改动 | 1.1「天生」→「盘面提示」;1.4「在教我什么」→「有哪些重点」;4.3「容易破财」→「需要多留财务余地」;7.1「缘分」→「倾向」;领域 9「身心状态」→「压力与生活节奏」;领域 11「今年与近期」→「未来十二个月」 |
|
||||
| 先问用户 | 1.2、2.2、2.4、3.2、4.2、4.4、5.2、5.3、6.3、6.4、7.2、7.3、8.2、9.1、9.4、10.3 等改为先请用户写下具体情况或选择事件类型 |
|
||||
| 局部删除 | 用八分法推断消费习惯;海外主线的 D12;从本人盘量化照护负担;把 11 宫固定当成年孩子;所有强制精确到旬的写法 |
|
||||
| 新增 | 每个领域 1 条候选问题,共 12 条 |
|
||||
@@ -386,6 +386,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke.
|
||||
- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place.
|
||||
- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again.
|
||||
- **Never clears (2026-11-02, BUG-1506):** the picker reports a place only when a fresh zone comes back. A half-chosen level or a failed lookup keeps the profile's previous place and offset; the picker reports *pending* instead, and the form's submit (`BirthPlaceSubmitButton` inside `BirthPlacePendingScope`: the edit dialogs' 保存, onboarding's 确定) stays disabled until the lookup succeeds or the reader goes back to the saved place and moment. A failed lookup on a person who already has a place reads 「时区这会儿没查到,原来的出生地还在,查到后才能保存。」 with 重试; with no place yet it stays 「时区服务暂时不可用,稍后重试即可。」 with 重试. The error replaces the status line in place: no extra hint row, no height change. An offset resolved for an edited date is not reused when the date goes back.
|
||||
- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region.
|
||||
|
||||
### Birth date picker
|
||||
|
||||
@@ -381,7 +381,7 @@ export async function POST(request: Request) {
|
||||
return released.success;
|
||||
},
|
||||
},
|
||||
runWorkflow: (input) => runConsultationWorkflow(input),
|
||||
runWorkflow: (input) => runConsultationWorkflow(input, { vedastroEvidenceBudget: "report" }),
|
||||
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
|
||||
skillSnapshot: resolveSkillSnapshot(),
|
||||
jobs: createSupabasePersonalReportJobService(admin),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useBirthPlacePendingReporter } from "@/components/birth-place-pending";
|
||||
import { BirthPlacePicker } from "@/components/birth-place-picker";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
|
||||
export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||||
// The enclosing form holds its submit while the picker still needs a fresh timezone (BUG-1506).
|
||||
const reportPending = useBirthPlacePendingReporter();
|
||||
return (
|
||||
<fieldset className="location-fieldset">
|
||||
<legend>出生地点</legend>
|
||||
@@ -18,21 +21,22 @@ export function BirthLocationFields({ value, onChange }: { value: Profile; onCha
|
||||
}}
|
||||
birthDate={value.date}
|
||||
birthTime={value.reportedTime || value.time}
|
||||
onPendingChange={reportPending}
|
||||
onChange={(location) => onChange({
|
||||
...value,
|
||||
countryCode: location?.countryCode || "CN",
|
||||
provinceCode: location?.provinceCode || "",
|
||||
cityCode: location?.cityCode || "",
|
||||
districtCode: location?.districtCode || "",
|
||||
birthPlaceLabel: location?.label || "",
|
||||
birthPlaceType: location?.placeType || "",
|
||||
birthPlaceProvider: location?.provider || "",
|
||||
birthPlaceProviderId: location?.providerPlaceId || "",
|
||||
timezoneId: location?.timezoneId || "",
|
||||
timezoneSource: location ? "iana_historical" : "",
|
||||
latitude: location?.latitude ?? null,
|
||||
longitude: location?.longitude ?? null,
|
||||
timezoneOffset: location?.timezoneOffset ?? null,
|
||||
countryCode: location.countryCode,
|
||||
provinceCode: location.provinceCode,
|
||||
cityCode: location.cityCode,
|
||||
districtCode: location.districtCode,
|
||||
birthPlaceLabel: location.label,
|
||||
birthPlaceType: location.placeType,
|
||||
birthPlaceProvider: location.provider,
|
||||
birthPlaceProviderId: location.providerPlaceId,
|
||||
timezoneId: location.timezoneId,
|
||||
timezoneSource: "iana_historical",
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
timezoneOffset: location.timezoneOffset,
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type BirthPlacePending = { readonly pending: boolean; readonly report: (pending: boolean) => void };
|
||||
|
||||
const BirthPlacePendingContext = createContext<BirthPlacePending | null>(null);
|
||||
|
||||
/**
|
||||
* Wraps a form that holds a birth place picker. While the picker still needs a fresh
|
||||
* timezone (unfinished levels, a lookup in flight or a failed lookup), the profile keeps
|
||||
* the previous place and offset, so the form's submit button stays disabled (BUG-1506).
|
||||
*/
|
||||
export function BirthPlacePendingScope({ children }: { readonly children: ReactNode }) {
|
||||
const [pending, report] = useState(false);
|
||||
const value = useMemo(() => ({ pending, report }), [pending]);
|
||||
return <BirthPlacePendingContext.Provider value={value}>{children}</BirthPlacePendingContext.Provider>;
|
||||
}
|
||||
|
||||
/** The picker inside a scope reports here; outside a scope there is nothing to hold. */
|
||||
export function useBirthPlacePendingReporter() {
|
||||
return useContext(BirthPlacePendingContext)?.report;
|
||||
}
|
||||
|
||||
/** The form's submit button, disabled while the scope's picker is pending. */
|
||||
export function BirthPlaceSubmitButton({ disabled, ...props }: Omit<ComponentProps<typeof Button>, "type">) {
|
||||
const pending = useContext(BirthPlacePendingContext)?.pending ?? false;
|
||||
return <Button {...props} type="submit" disabled={Boolean(disabled) || pending} />;
|
||||
}
|
||||
@@ -50,7 +50,14 @@ type BirthPlacePickerProps = {
|
||||
birthDate?: string;
|
||||
birthTime?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (selection: BirthPlaceSelection | null) => void;
|
||||
/** Reports a freshly resolved place only. A failed or unfinished choice never clears the value. */
|
||||
onChange: (selection: BirthPlaceSelection) => void;
|
||||
/**
|
||||
* True while the shown choice or birth moment needs a timezone the value does not carry yet
|
||||
* (unfinished levels, a lookup in flight, or a failed lookup). The form must not save then:
|
||||
* the value still holds the previous place and offset (BUG-1506).
|
||||
*/
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
};
|
||||
|
||||
type TimezoneState =
|
||||
@@ -97,6 +104,10 @@ function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(draft: BirthPlaceDraft) {
|
||||
return `${draft.countryCode}/${draft.provinceCode}/${draft.cityCode}/${draft.districtCode}/${draft.cityCentre ? "centre" : ""}`;
|
||||
}
|
||||
|
||||
function countryLabel(countryCode: string) {
|
||||
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
|
||||
}
|
||||
@@ -107,14 +118,15 @@ export function BirthPlacePicker({
|
||||
birthTime = "",
|
||||
disabled = false,
|
||||
onChange,
|
||||
onPendingChange,
|
||||
}: BirthPlacePickerProps) {
|
||||
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
|
||||
const fieldPrefix = useId();
|
||||
const onChangeRef = useRef(onChange);
|
||||
const savedRef = useRef(value);
|
||||
const onPendingChangeRef = useRef(onPendingChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
savedRef.current = value;
|
||||
onPendingChangeRef.current = onPendingChange;
|
||||
});
|
||||
|
||||
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
|
||||
@@ -124,11 +136,15 @@ export function BirthPlacePicker({
|
||||
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
|
||||
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
// The birth moment the value's offset was resolved for. A historical offset depends on it.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [valueMoment, setValueMoment] = useState(birthMoment);
|
||||
|
||||
// Adopt a profile that was loaded or replaced outside this picker.
|
||||
// Adopt a profile that was loaded or replaced outside this picker, or the place this picker just resolved.
|
||||
if (syncedKey !== externalKey) {
|
||||
setSyncedKey(externalKey);
|
||||
setDraft(draftFromValue(value));
|
||||
setValueMoment(birthMoment);
|
||||
}
|
||||
|
||||
const isChina = draft.countryCode === "CN";
|
||||
@@ -208,12 +224,15 @@ export function BirthPlacePicker({
|
||||
}
|
||||
: globalResolved;
|
||||
|
||||
// The offset the profile already carries is only authoritative while the birth moment is unchanged.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [mountedMoment] = useState(birthMoment);
|
||||
// The offset the profile already carries is only authoritative for the birth moment it was resolved for.
|
||||
const valueHasZone = value.timezoneId.trim() !== "";
|
||||
const valueHasPlace = valueHasZone || countryCode !== "CN" || Boolean(value.provinceCode);
|
||||
const valueIsCurrent = valueHasZone
|
||||
&& valueMoment === birthMoment
|
||||
&& draftKey(draft) === draftKey(draftFromValue(value));
|
||||
const savedIsCurrent = resolved !== null
|
||||
&& mountedMoment === birthMoment
|
||||
&& value.timezoneId.trim() !== ""
|
||||
&& valueMoment === birthMoment
|
||||
&& valueHasZone
|
||||
&& countryCode === resolved.countryCode
|
||||
&& value.provinceCode === resolved.provinceCode
|
||||
&& value.cityCode === resolved.cityCode
|
||||
@@ -226,14 +245,11 @@ export function BirthPlacePicker({
|
||||
: "empty";
|
||||
|
||||
useEffect(() => {
|
||||
if (syncKey === "saved") return;
|
||||
if (syncKey === "empty") {
|
||||
const saved = savedRef.current;
|
||||
if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
|
||||
return;
|
||||
}
|
||||
// An unfinished choice keeps the previous value; the pending flag holds the save instead.
|
||||
if (syncKey === "saved" || syncKey === "empty") return;
|
||||
|
||||
const target = resolved as NonNullable<typeof resolved>;
|
||||
const lookupMoment = birthMoment;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
@@ -254,7 +270,9 @@ export function BirthPlacePicker({
|
||||
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
|
||||
if (!timezoneId) throw new Error("timezone_unavailable");
|
||||
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
|
||||
if (controller.signal.aborted) return;
|
||||
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
|
||||
setValueMoment(lookupMoment);
|
||||
onChangeRef.current({
|
||||
countryCode: target.countryCode,
|
||||
provinceCode: target.provinceCode,
|
||||
@@ -271,8 +289,8 @@ export function BirthPlacePicker({
|
||||
});
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
// Keep the value: a failed lookup must not erase the saved place (BUG-1506).
|
||||
setOutcome({ key: syncKey, state: { status: "error" } });
|
||||
onChangeRef.current(null);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
@@ -320,6 +338,14 @@ export function BirthPlacePicker({
|
||||
: outcome?.key === syncKey
|
||||
? outcome.state
|
||||
: { status: "loading" };
|
||||
const pending = syncKey === "saved"
|
||||
? false
|
||||
: syncKey === "empty"
|
||||
? valueHasPlace && !valueIsCurrent
|
||||
: settled.status !== "ready";
|
||||
useEffect(() => { onPendingChangeRef.current?.(pending); }, [pending]);
|
||||
useEffect(() => () => onPendingChangeRef.current?.(false), []);
|
||||
|
||||
const globalIsLoading = !isChina
|
||||
&& globalData?.code !== draft.countryCode
|
||||
&& globalLoadErrorCountry !== draft.countryCode;
|
||||
@@ -329,7 +355,9 @@ export function BirthPlacePicker({
|
||||
: settled.status === "ready" && resolved
|
||||
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
|
||||
: settled.status === "error"
|
||||
? "时区服务暂时不可用,稍后重试即可。"
|
||||
? valueHasPlace
|
||||
? "时区这会儿没查到,原来的出生地还在,查到后才能保存。"
|
||||
: "时区服务暂时不可用,稍后重试即可。"
|
||||
: globalIsLoading
|
||||
? "正在加载该国家的地区列表…"
|
||||
: globalHasError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
|
||||
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
|
||||
import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProfileFields } from "@/components/profile-fields";
|
||||
import { PersonRelationField } from "@/components/person-relation-field";
|
||||
@@ -48,39 +49,41 @@ export function ChartProfileForm({
|
||||
notice,
|
||||
}: ChartProfileFormProps) {
|
||||
return (
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<Button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<BirthPlaceSubmitButton className="button-primary save-profile" disabled={submitDisabled}>{submitLabel}</BirthPlaceSubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { rememberSkyOrigin } from "@/components/birth-sky/sky-origin";
|
||||
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
|
||||
@@ -130,14 +131,16 @@ export function HomeOnboardingShell({
|
||||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal" data-onboarding-enter="place-card" data-onboarding-index={4}>
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><Button className="button-primary" type="submit" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</Button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><BirthPlaceSubmitButton className="button-primary" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</BirthPlaceSubmitButton></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,6 +71,7 @@ const NOTE_LABELS: Readonly<Record<string, string>> = {
|
||||
official_raw_archive_is_auditable: "官方原始档案可审计",
|
||||
official_cloud_evidence_available: "官方云证据可用",
|
||||
foreground_optional_evidence_timeout: "前台官方云证据超时,本轮未赶上",
|
||||
report_vedastro_deadline_exceeded: "报告等待官方云证据超过时限,本次未采用",
|
||||
gateway_invocation_error: "官方云网关调用失败",
|
||||
claims_capped_until_pyjhora_jhora_jyotishganit_are_invoked_for_this_run: "结论上限受外部引擎调用限制",
|
||||
three_engine_runtime_closure_available: "三引擎运行时已闭环",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GeneratePersonalReportResult } from "./personal-report-generation.
|
||||
import type { PromptCacheUsage } from "./agent-generation-settings.ts";
|
||||
import type { PersonalReportSectionService } from "./personal-report-section-service-core.ts";
|
||||
import type { ReportBillingPort } from "./personal-report-billing.ts";
|
||||
import { logAgentObservability, type AgentObservabilityLogger } from "./agent-observability.ts";
|
||||
|
||||
/**
|
||||
* Durable personal-report worker orchestration.
|
||||
@@ -106,6 +107,8 @@ export type PersonalReportWorkerDeps = Readonly<{
|
||||
) => Promise<void>;
|
||||
sectionService?: PersonalReportSectionService;
|
||||
billing?: ReportBillingPort;
|
||||
/** Records refund failures (`errorCode=report_refund_failed`); defaults to the observability log. */
|
||||
logObservability?: AgentObservabilityLogger;
|
||||
leaseSeconds?: number;
|
||||
heartbeatIntervalMs?: number;
|
||||
recoveryLimit?: number;
|
||||
@@ -123,6 +126,7 @@ export type PersonalReportWorkerTickResult = Readonly<{
|
||||
| "failed"
|
||||
| "retry_scheduled"
|
||||
| "reconcile_deferred"
|
||||
| "refund_deferred"
|
||||
| "lease_lost";
|
||||
jobId: string | null;
|
||||
}>;
|
||||
@@ -223,7 +227,36 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval.bind(globalThis);
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
|
||||
const logObservability = deps.logObservability ?? logAgentObservability;
|
||||
|
||||
/**
|
||||
* Release the reservation before a job is closed as failed. The billing RPC
|
||||
* already retries with the same request id; if it still fails (throws, or
|
||||
* returns false), the failure is logged with `report_refund_failed` and the
|
||||
* caller leaves the live lease alone. The job is then never closed with the
|
||||
* credits still held: after the lease expires the recovery sweep either
|
||||
* retries the attempt or, on the last attempt, fails the report and releases
|
||||
* in one transaction (BUG-1505).
|
||||
*/
|
||||
async function releaseBeforeFailure(job: PersonalReportJobRecord, reason: string): Promise<boolean> {
|
||||
if (!deps.billing) return true;
|
||||
try {
|
||||
if (await deps.billing.release({ userId: job.userId, requestId: job.requestId, reason })) return true;
|
||||
} catch {
|
||||
// Recorded below; the error text is not logged.
|
||||
}
|
||||
try {
|
||||
logObservability({
|
||||
requestId: job.requestId,
|
||||
runId: job.id,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
} catch {
|
||||
// Logging must not change the job outcome.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function settleFailure(
|
||||
job: PersonalReportJobRecord,
|
||||
@@ -237,46 +270,35 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
const terminal = !isRetryable(error) || job.attemptCount >= job.maxAttempts;
|
||||
|
||||
try {
|
||||
if (terminal) {
|
||||
if (deps.billing) {
|
||||
await deps.billing.release({ userId: job.userId, requestId: job.requestId, reason: code });
|
||||
}
|
||||
|
||||
if (!report || report.status !== "generating") {
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
});
|
||||
} else {
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
}
|
||||
return "failed";
|
||||
if (!terminal) {
|
||||
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
|
||||
const scheduled = await deps.jobs.scheduleRetry({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
nextAttemptAt: retryAt,
|
||||
});
|
||||
if (scheduled.kind === "scheduled") return "retry_scheduled";
|
||||
// Retry budget exhausted: close it like any other terminal failure.
|
||||
}
|
||||
|
||||
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
|
||||
const scheduled = await deps.jobs.scheduleRetry({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
nextAttemptAt: retryAt,
|
||||
});
|
||||
if (scheduled.kind === "scheduled") return "retry_scheduled";
|
||||
if (!(await releaseBeforeFailure(job, code))) return "refund_deferred";
|
||||
|
||||
if (!report || report.status !== "generating") return "failed";
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
if (!report || report.status !== "generating") {
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
});
|
||||
} else {
|
||||
await deps.jobs.completeReportFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
errorCode: code,
|
||||
report,
|
||||
failureCode,
|
||||
});
|
||||
}
|
||||
return "failed";
|
||||
} catch (transitionError) {
|
||||
if (isLeaseLoss(transitionError)) return "lease_lost";
|
||||
@@ -328,6 +350,9 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
return "ready";
|
||||
}
|
||||
if (report.status === "failed") {
|
||||
if (!(await releaseBeforeFailure(job, report.failureCode ?? "report_generation_failed"))) {
|
||||
return "refund_deferred";
|
||||
}
|
||||
await deps.jobs.markFailed({
|
||||
jobId: job.id,
|
||||
leaseToken: job.leaseToken,
|
||||
|
||||
@@ -134,7 +134,7 @@ async function generateWriterReport(
|
||||
try {
|
||||
workflows.push({
|
||||
theme: rawTheme,
|
||||
workflow: await runConsultationWorkflow(input, { signal: context.signal }),
|
||||
workflow: await runConsultationWorkflow(input, { signal: context.signal, vedastroEvidenceBudget: "report" }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (context.signal.aborted) throw error;
|
||||
@@ -303,7 +303,15 @@ function createProductionWorker(workerId: string) {
|
||||
});
|
||||
return settled.success;
|
||||
},
|
||||
release: async ({ userId, requestId, reason }) => (await releaseUsage(admin, userId, requestId, reason)).success,
|
||||
release: async ({ userId, requestId, reason }) => {
|
||||
const released = await releaseUsage(admin, userId, requestId, reason);
|
||||
// Final outcomes with nothing left to give back, as in the delete
|
||||
// trigger: already charged, or the wallet is gone. Anything else is a
|
||||
// refund failure the worker must not paper over (BUG-1505).
|
||||
return released.success
|
||||
|| released.error_code === "request_completed"
|
||||
|| released.error_code === "profile_missing";
|
||||
},
|
||||
reserve: async () => { throw new Error("report worker does not reserve usage"); },
|
||||
},
|
||||
loadProfile: (userId, subjectId) => loadReportWorkerProfile(admin, userId, subjectId),
|
||||
|
||||
@@ -24,9 +24,39 @@ import { dropUngroundedFactSentences } from "./spoken-grounding";
|
||||
import { evidenceTurnRecap, RECTIFICATION_USER_COPY } from "../user-copy";
|
||||
import { moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import type { V9AgentRunResult } from "./agent-run";
|
||||
import { logAgentObservability } from "@/lib/agent-observability";
|
||||
import type { V9AgentRunResult, V9RunBilling } from "./agent-run";
|
||||
import type { V9TimedTurn } from "./agent-run-prepare";
|
||||
|
||||
/**
|
||||
* Release the turn's reservation. `billing.release()` reports failure as
|
||||
* `false` (the RPC underneath already retries); one more try with the same
|
||||
* case-bound request id covers a failed request-id lookup, and a release that
|
||||
* still fails is logged with `rectification_refund_failed` instead of being
|
||||
* dropped. The reservation stays reserved under the case's request id, so the
|
||||
* next run of this case reuses it rather than charging again (BUG-1505).
|
||||
*/
|
||||
async function releaseTurnBilling(billing: V9RunBilling, caseId: string, turnId: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
if (await billing.release()) return true;
|
||||
} catch {
|
||||
// Treated like `false`; the error text is not logged.
|
||||
}
|
||||
}
|
||||
try {
|
||||
logAgentObservability({
|
||||
caseId,
|
||||
...(turnId ? { requestId: turnId } : {}),
|
||||
errorCode: "rectification_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
} catch {
|
||||
// Logging must not change the turn outcome.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function finishV9AgentTurn(
|
||||
turn: V9TimedTurn,
|
||||
outcome: AttemptOutcome,
|
||||
@@ -46,7 +76,7 @@ export async function finishV9AgentTurn(
|
||||
const { persistCommittedPhase, finalizeTurn } = v9TurnReceipts(turn);
|
||||
|
||||
if (!outcome.ok) {
|
||||
await billing.release();
|
||||
await releaseTurnBilling(billing, caseId, turnId);
|
||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||
await emit({
|
||||
type: "run.failed",
|
||||
@@ -121,7 +151,7 @@ export async function finishV9AgentTurn(
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
await billing.release();
|
||||
await releaseTurnBilling(billing, caseId, turnId);
|
||||
}
|
||||
|
||||
await persistCommittedPhase(
|
||||
|
||||
@@ -137,6 +137,12 @@ export async function runConsultationWorkflow(
|
||||
input: ConsultationInput,
|
||||
options?: {
|
||||
foreground?: boolean;
|
||||
/**
|
||||
* "report": the API waits for VedAstro only up to its report deadline
|
||||
* (well inside the 90 s below), then marks it blocked and remembers the
|
||||
* failure for the day. BUG-1508.
|
||||
*/
|
||||
vedastroEvidenceBudget?: "report";
|
||||
signal?: AbortSignal;
|
||||
plan?: ConsultationPlan;
|
||||
requestId?: string;
|
||||
@@ -161,6 +167,7 @@ export async function runConsultationWorkflow(
|
||||
question_text: workflowRequest.question,
|
||||
theme: workflowRequest.themes,
|
||||
defer_optional_external_evidence: options?.foreground === true,
|
||||
...(options?.vedastroEvidenceBudget ? { vedastro_evidence_budget: options.vedastroEvidenceBudget } : {}),
|
||||
plan_version: workflowRequest.plan_version,
|
||||
strict_workflow_route: workflowRequest.strictWorkflowRoute,
|
||||
required_layers: workflowRequest.requiredLayers,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
begin;
|
||||
|
||||
-- BUG-1505: a report job whose lease expired on its last attempt was failed by
|
||||
-- recover_expired_personal_report_jobs, but its reserved credits were never
|
||||
-- released. Only deleting the report (20260925040000) gave them back.
|
||||
--
|
||||
-- Every report type (personal_full / personal_thematic, synastry_full,
|
||||
-- annual_full) reserves under the report's own request_id, the same key the
|
||||
-- worker and the delete trigger release with. release_usage is idempotent on
|
||||
-- that key, so the worker's failure path, this sweep and the delete trigger
|
||||
-- refund at most once between them.
|
||||
--
|
||||
-- A job ends failed here only after its release has succeeded (or there is
|
||||
-- nothing left to release: already charged, or the wallet is gone). If the
|
||||
-- release raises or returns another error, that row is left running with its
|
||||
-- expired lease and the next sweep tries again. One bad row never blocks the
|
||||
-- rest of the batch.
|
||||
--
|
||||
-- Signature, return type and security mode (invoker, service_role only) are
|
||||
-- unchanged. service_role owns the table rights on personal_report_jobs and
|
||||
-- personal_reports (20260814040000, 20260806010000) and EXECUTE on
|
||||
-- release_usage (20260806030000); release_usage is not among the functions
|
||||
-- whose EXECUTE 20260814010000 withholds.
|
||||
|
||||
create or replace function public.recover_expired_personal_report_jobs(
|
||||
p_limit integer default 100
|
||||
)
|
||||
returns table (retrying_count integer, failed_count integer)
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_recovered_at timestamptz := pg_catalog.clock_timestamp();
|
||||
v_candidate record;
|
||||
v_job_ids uuid[] := '{}'::uuid[];
|
||||
v_release_success boolean;
|
||||
v_release_error text;
|
||||
begin
|
||||
if p_limit not between 1 and 1000 then
|
||||
raise exception using errcode = '22023', message = 'personal_report_job_recovery_limit_invalid';
|
||||
end if;
|
||||
|
||||
-- Lock order matches the worker and delete_chart_subject: job, then report,
|
||||
-- then (inside release_usage) the reservation and wallet.
|
||||
for v_candidate in
|
||||
select
|
||||
job.id,
|
||||
job.user_id,
|
||||
job.request_id,
|
||||
job.attempt_count,
|
||||
job.max_attempts,
|
||||
report.status as report_status
|
||||
from public.personal_report_jobs as job
|
||||
join public.personal_reports as report
|
||||
on report.user_id = job.user_id
|
||||
and report.request_id = job.request_id
|
||||
where job.status = 'running'
|
||||
and job.lease_expires_at <= v_recovered_at
|
||||
order by job.lease_expires_at, job.id
|
||||
for update skip locked
|
||||
limit p_limit
|
||||
loop
|
||||
-- Same rule as the status case below: not ready, and either already failed
|
||||
-- or out of attempts, means this row ends failed.
|
||||
if v_candidate.report_status <> 'ready'
|
||||
and (
|
||||
v_candidate.report_status = 'failed'
|
||||
or v_candidate.attempt_count >= v_candidate.max_attempts
|
||||
) then
|
||||
begin
|
||||
select released.success, released.error_code
|
||||
into v_release_success, v_release_error
|
||||
from public.release_usage(
|
||||
v_candidate.user_id,
|
||||
v_candidate.request_id::text,
|
||||
case
|
||||
when v_candidate.report_status = 'failed' then 'report_failed_lease_expired'
|
||||
else 'lease_expired_attempts_exhausted'
|
||||
end
|
||||
) as released;
|
||||
exception when others then
|
||||
v_release_success := false;
|
||||
v_release_error := 'release_raised:' || sqlstate;
|
||||
end;
|
||||
|
||||
if not coalesce(v_release_success, false)
|
||||
and v_release_error is distinct from 'request_completed'
|
||||
and v_release_error is distinct from 'profile_missing' then
|
||||
raise warning using
|
||||
message = 'personal_report_job_recovery_release_deferred',
|
||||
detail = pg_catalog.format(
|
||||
'errorCode=report_refund_failed job=%s reason=%s',
|
||||
v_candidate.id,
|
||||
coalesce(v_release_error, 'unknown')
|
||||
);
|
||||
continue;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
v_job_ids := pg_catalog.array_append(v_job_ids, v_candidate.id);
|
||||
end loop;
|
||||
|
||||
return query
|
||||
with candidates as (
|
||||
select
|
||||
job.id,
|
||||
job.attempt_count,
|
||||
job.max_attempts,
|
||||
report.id as report_id,
|
||||
report.status as report_status,
|
||||
report.failure_code as report_failure_code
|
||||
from public.personal_report_jobs as job
|
||||
join public.personal_reports as report
|
||||
on report.user_id = job.user_id
|
||||
and report.request_id = job.request_id
|
||||
where job.id = any(v_job_ids)
|
||||
), exhausted_reports as (
|
||||
update public.personal_reports as report
|
||||
set status = 'failed',
|
||||
failure_code = 'calculation_unavailable',
|
||||
updated_at = v_recovered_at
|
||||
from candidates
|
||||
where report.id = candidates.report_id
|
||||
and candidates.report_status = 'generating'
|
||||
and candidates.attempt_count >= candidates.max_attempts
|
||||
returning report.id
|
||||
), recovered as (
|
||||
update public.personal_report_jobs as job
|
||||
set status = case
|
||||
when candidates.report_status = 'ready' then 'ready'
|
||||
when candidates.report_status = 'failed' then 'failed'
|
||||
when job.attempt_count < job.max_attempts then 'retrying'
|
||||
else 'failed'
|
||||
end,
|
||||
lease_token = null,
|
||||
lease_owner = null,
|
||||
lease_acquired_at = null,
|
||||
lease_expires_at = null,
|
||||
heartbeat_at = case
|
||||
when candidates.report_status = 'ready'
|
||||
or exhausted_reports.id is not null then v_recovered_at
|
||||
else job.heartbeat_at
|
||||
end,
|
||||
next_attempt_at = case
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then v_recovered_at
|
||||
else null
|
||||
end,
|
||||
progress_phase = case
|
||||
when candidates.report_status = 'ready' then 'ready'
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then 'retry_wait'
|
||||
else 'failed'
|
||||
end,
|
||||
progress_percent = case
|
||||
when candidates.report_status = 'ready' then 100
|
||||
else job.progress_percent
|
||||
end,
|
||||
last_error_code = case
|
||||
when candidates.report_status = 'ready' then null
|
||||
when candidates.report_status = 'failed' then coalesce(candidates.report_failure_code, 'report_generation_failed')
|
||||
when job.attempt_count < job.max_attempts then 'lease_expired'
|
||||
else 'lease_expired_attempts_exhausted'
|
||||
end,
|
||||
last_error_at = case
|
||||
when candidates.report_status = 'ready' then null
|
||||
else v_recovered_at
|
||||
end,
|
||||
finished_at = case
|
||||
when candidates.report_status = 'generating'
|
||||
and job.attempt_count < job.max_attempts then null
|
||||
else v_recovered_at
|
||||
end
|
||||
from candidates
|
||||
left join exhausted_reports on exhausted_reports.id = candidates.report_id
|
||||
where job.id = candidates.id
|
||||
returning job.status
|
||||
)
|
||||
select
|
||||
(count(*) filter (where recovered.status = 'retrying'))::integer,
|
||||
(count(*) filter (where recovered.status = 'failed'))::integer
|
||||
from recovered;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.recover_expired_personal_report_jobs(integer) from public, anon, authenticated;
|
||||
grant execute on function public.recover_expired_personal_report_jobs(integer) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -111,10 +111,18 @@ test("timezone is resolved once for the chosen place rather than during browsing
|
||||
assert.equal(existsSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url)), true);
|
||||
assert.match(pickerSource, /fetch\("\/api\/locations\/timezone"/);
|
||||
// Browsing the levels cannot spend a lookup: only a resolved place gets a sync key.
|
||||
assert.match(pickerSource, /if \(syncKey === "empty"\)/);
|
||||
// 原值:`if (syncKey === "empty")`(单独一个分支,里面上报 null)。
|
||||
// 新值:`if (syncKey === "saved" || syncKey === "empty") return;`,没选完不发请求也不上报。
|
||||
// 原因:BUG-1506,见下一条测试。
|
||||
assert.match(pickerSource, /syncKey === "empty"\) return;/);
|
||||
assert.match(pickerSource, /: \{ status: "loading" \};/);
|
||||
// An unresolved zone must not be reported upward as a usable birth place.
|
||||
assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*onChangeRef\.current\(null\);/);
|
||||
// 原值:断言失败分支 `setOutcome(error)` 之后紧跟 `onChangeRef.current(null)`。
|
||||
// 新值:失败分支只记错误,不再上报 null;选择器的 onChange 也不再接受 null。
|
||||
// 原因:BUG-1506,上报 null 会把已存的出生地清空。现在靠 onPendingChange 拦住保存,行为测试在 birth-place-timezone-failure-20261102.test.tsx。
|
||||
assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*\}/);
|
||||
assert.doesNotMatch(pickerSource, /onChangeRef\.current\(null\)/);
|
||||
assert.match(pickerSource, /onChange: \(selection: BirthPlaceSelection\) => void;/);
|
||||
|
||||
const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8");
|
||||
assert.match(service, /\/api\/location\/timezone/);
|
||||
@@ -125,19 +133,27 @@ test("mounting a saved birth place neither refetches nor clears the stored locat
|
||||
// Reporting null on mount would look like a location edit and would drop a
|
||||
// rectified birth-time candidate just because the profile dialog was opened.
|
||||
assert.match(pickerSource, /const savedIsCurrent = resolved !== null/);
|
||||
assert.match(pickerSource, /if \(syncKey === "saved"\) return;/);
|
||||
assert.match(pickerSource, /if \(saved\.countryCode !== "CN" \|\| saved\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/);
|
||||
// 原值:断言 `if (syncKey === "saved") return;`,以及选空时 `if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);`。
|
||||
// 新值:已存和没选完都直接返回,不上报。
|
||||
// 原因:BUG-1506,没选完时上报 null 会清空已存地点,草稿也跟着按空值重置(改省份、改国家、打开海外人物都会中招)。
|
||||
assert.match(pickerSource, /if \(syncKey === "saved" \|\| syncKey === "empty"\) return;/);
|
||||
// A changed birth date invalidates the stored offset, so the zone is resolved again.
|
||||
assert.match(pickerSource, /mountedMoment === birthMoment/);
|
||||
// 原值:`mountedMoment === birthMoment`。
|
||||
// 新值:`valueMoment === birthMoment`,valueMoment 是资料里这份时区对应的出生时刻。
|
||||
// 原因:BUG-1506,改日期查到新时区后再改回原日期,旧写法会把新日期的时区当成原日期的存下去。
|
||||
assert.match(pickerSource, /valueMoment === birthMoment/);
|
||||
});
|
||||
|
||||
test("the profile stores administrative codes alongside coordinates and the IANA zone", () => {
|
||||
assert.match(pageSource, /<BirthPlacePicker/);
|
||||
assert.match(pageSource, /provinceCode: location\?\.provinceCode \|\| ""/);
|
||||
assert.match(pageSource, /cityCode: location\?\.cityCode \|\| ""/);
|
||||
assert.match(pageSource, /districtCode: location\?\.districtCode \|\| ""/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\?\.provider \|\| ""/);
|
||||
assert.match(pageSource, /timezoneSource: location \? "iana_historical" : ""/);
|
||||
// 原值:`provinceCode: location?.provinceCode || ""` 等可空写法,以及 `timezoneSource: location ? "iana_historical" : ""`。
|
||||
// 新值:`provinceCode: location.provinceCode` 等,`timezoneSource: "iana_historical"`。
|
||||
// 原因:BUG-1506,选择器不再上报 null,父组件不再有把地点清空的分支。
|
||||
assert.match(pageSource, /provinceCode: location\.provinceCode,/);
|
||||
assert.match(pageSource, /cityCode: location\.cityCode,/);
|
||||
assert.match(pageSource, /districtCode: location\.districtCode,/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\.provider,/);
|
||||
assert.match(pageSource, /timezoneSource: "iana_historical",/);
|
||||
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
|
||||
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
import { ChartProfileForm } from "../src/components/chart-profile-form";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types";
|
||||
import { selectedBirthPlace } from "../src/lib/home-profile";
|
||||
import usRegions from "../public/data/world-locations/us.json";
|
||||
|
||||
// BUG-1506 (2026-11-02): editing the birth date, time or place of a saved person
|
||||
// while /api/locations/timezone fails used to erase the saved place silently.
|
||||
// These tests mount the real edit form with a fetch stub.
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const EDIT_FAILURE = "时区这会儿没查到,原来的出生地还在,查到后才能保存。";
|
||||
const NEW_FAILURE = "时区服务暂时不可用,稍后重试即可。";
|
||||
|
||||
const savedHandan: Profile = {
|
||||
...emptyProfile,
|
||||
name: "甲",
|
||||
date: "1990-05-01",
|
||||
time: "10:00",
|
||||
reportedTime: "10:00",
|
||||
birthTimeSource: "hospital_record",
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130402",
|
||||
birthPlaceLabel: "中国 · 河北省 · 邯郸市 · 邯山区",
|
||||
birthPlaceType: "district",
|
||||
birthPlaceProvider: "china_locations",
|
||||
birthPlaceProviderId: "130402",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneSource: "iana_historical",
|
||||
latitude: 36.603196,
|
||||
longitude: 114.484989,
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
|
||||
type Reply = "fail" | { timezoneId: string; timezoneOffset: number };
|
||||
|
||||
async function mountForm(initial: Profile, options: { regions?: "ok" | "fail" } = {}) {
|
||||
const harness = createClientLifecycleHarness();
|
||||
const lookups: unknown[] = [];
|
||||
let reply: Reply = "fail";
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/data/world-locations/")) {
|
||||
return options.regions === "fail"
|
||||
? new Response("", { status: 503 })
|
||||
: new Response(JSON.stringify(usRegions), { status: 200 });
|
||||
}
|
||||
lookups.push(JSON.parse(String(init?.body ?? "{}")));
|
||||
if (reply === "fail") return new Response("{}", { status: 503 });
|
||||
return new Response(JSON.stringify(reply), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const state: { profile: Profile; set: (next: Profile) => void } = { profile: initial, set: () => {} };
|
||||
function Host() {
|
||||
const [profile, setProfile] = useState(initial);
|
||||
state.profile = profile;
|
||||
state.set = setProfile;
|
||||
return (
|
||||
<ChartProfileForm
|
||||
title="编辑人物"
|
||||
description="称呼和出生资料。"
|
||||
value={profile}
|
||||
onChange={setProfile}
|
||||
nameInputId="bug-1506"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
submitLabel="保存"
|
||||
submitDisabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
await harness.render(<Host />);
|
||||
await harness.idle();
|
||||
|
||||
const byClass = (name: string) => harness.elements().find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes(name));
|
||||
return {
|
||||
lookups,
|
||||
state,
|
||||
reply(next: Reply) { reply = next; },
|
||||
status: () => byClass("birth-place-status")?.text ?? "",
|
||||
retry: () => byClass("birth-place-retry"),
|
||||
saveDisabled: () => Boolean(byClass("save-profile")?.hasAttribute("disabled")),
|
||||
async edit(patch: Partial<Profile>) {
|
||||
await harness.update(() => state.set({ ...state.profile, ...patch }));
|
||||
await harness.idle();
|
||||
},
|
||||
async choose(levelLabel: string, code: string) {
|
||||
const nodes = harness.elements();
|
||||
const trigger = nodes.findIndex((node) => node.getAttribute("aria-label") === levelLabel);
|
||||
assert.notEqual(trigger, -1, `${levelLabel} is rendered`);
|
||||
const input = nodes.slice(trigger).find((node) => node.tagName === "INPUT");
|
||||
assert.ok(input, `${levelLabel} has its hidden input`);
|
||||
await harness.event(input, "onChange", { target: Object.assign(input, { value: code }) });
|
||||
await harness.idle();
|
||||
},
|
||||
async clickRetry() {
|
||||
await harness.event(byClass("birth-place-retry")!);
|
||||
await harness.idle();
|
||||
},
|
||||
async close() {
|
||||
try { await harness.close(); } finally { globalThis.fetch = originalFetch; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertStillHandan(profile: Profile) {
|
||||
assert.equal(profile.provinceCode, "130000");
|
||||
assert.equal(profile.cityCode, "130400");
|
||||
assert.equal(profile.districtCode, "130402");
|
||||
assert.equal(profile.birthPlaceLabel, "中国 · 河北省 · 邯郸市 · 邯山区");
|
||||
assert.equal(profile.latitude, 36.603196);
|
||||
assert.equal(profile.timezoneId, "Asia/Shanghai");
|
||||
assert.ok(selectedBirthPlace(profile), "the saved place is still a usable birth place");
|
||||
}
|
||||
|
||||
test("a failed lookup after a date edit keeps the saved place, shows the error with a retry and holds the save", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
// Opening the saved person neither looks the zone up nor holds the save.
|
||||
assert.equal(form.lookups.length, 0);
|
||||
assert.match(form.status(), /邯山区.*Asia\/Shanghai · UTC\+8/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
|
||||
await form.edit({ date: "1990-05-02" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.deepEqual(form.lookups[0], { latitude: 36.603196, longitude: 114.484989, birthDate: "1990-05-02", birthTime: "10:00" });
|
||||
assert.ok(form.status().includes(EDIT_FAILURE), form.status());
|
||||
assert.doesNotMatch(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.ok(form.retry(), "the error carries a retry");
|
||||
assertStillHandan(form.state.profile);
|
||||
// The stored offset belongs to the old date: it must not be saved for the new one.
|
||||
assert.equal(form.state.profile.timezoneOffset, 8);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.lookups.length, 2);
|
||||
assert.ok(!form.status().includes(EDIT_FAILURE));
|
||||
assert.match(form.status(), /Asia\/Shanghai · UTC\+9/);
|
||||
assert.equal(form.retry(), undefined);
|
||||
assert.equal(form.state.profile.timezoneOffset, 9);
|
||||
assert.equal(form.state.profile.date, "1990-05-02");
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reverting the edit after a failed lookup restores the saved offset without another lookup", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
await form.edit({ reportedTime: "11:00", time: "11:00" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
await form.edit({ reportedTime: "10:00", time: "10:00" });
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.match(form.status(), /Asia\/Shanghai · UTC\+8/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an offset resolved for an edited date is looked up again when the date goes back", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
|
||||
await form.edit({ date: "1990-05-02" });
|
||||
assert.equal(form.state.profile.timezoneOffset, 9);
|
||||
|
||||
form.reply("fail");
|
||||
await form.edit({ date: "1990-05-01" });
|
||||
// 9 was resolved for 05-02; saving it silently for 05-01 would be the stale offset.
|
||||
assert.equal(form.lookups.length, 2);
|
||||
assert.ok(form.status().includes(EDIT_FAILURE));
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the place keeps the saved place while choosing and after a failed lookup", async () => {
|
||||
const form = await mountForm(savedHandan);
|
||||
try {
|
||||
await form.choose("出生省份", "440000");
|
||||
// The half-chosen province stays on screen instead of snapping back to an empty picker.
|
||||
assert.match(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.ok(form.state.profile.provinceCode === "130000");
|
||||
assertStillHandan(form.state.profile);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
assert.equal(form.lookups.length, 0);
|
||||
|
||||
await form.choose("出生城市", "441900");
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.ok(form.status().includes(EDIT_FAILURE));
|
||||
assertStillHandan(form.state.profile);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.state.profile.provinceCode, "440000");
|
||||
assert.equal(form.state.profile.cityCode, "441900");
|
||||
assert.match(form.state.profile.birthPlaceLabel, /东莞市/);
|
||||
assert.match(form.status(), /东莞市/);
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new person with no saved place gets the retry and no usable place on failure", async () => {
|
||||
const form = await mountForm({ ...emptyProfile, name: "乙", date: "1992-03-04", reportedTime: "08:00", time: "08:00" });
|
||||
try {
|
||||
assert.equal(form.lookups.length, 0);
|
||||
assert.match(form.status(), /按省、市、区县逐级选择即可/);
|
||||
assert.equal(form.saveDisabled(), false, "the picker alone holds nothing while nothing is chosen");
|
||||
|
||||
await form.choose("出生省份", "440000");
|
||||
await form.choose("出生城市", "441900");
|
||||
assert.equal(form.lookups.length, 1);
|
||||
assert.ok(form.status().includes(NEW_FAILURE), form.status());
|
||||
assert.ok(form.retry());
|
||||
assert.equal(form.state.profile.provinceCode, "");
|
||||
assert.equal(selectedBirthPlace(form.state.profile), null);
|
||||
assert.equal(form.saveDisabled(), true);
|
||||
|
||||
form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
|
||||
await form.clickRetry();
|
||||
assert.equal(form.state.profile.cityCode, "441900");
|
||||
assert.equal(form.state.profile.timezoneOffset, 8);
|
||||
assert.ok(selectedBirthPlace(form.state.profile));
|
||||
assert.equal(form.saveDisabled(), false);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening a saved overseas person keeps the place while the region list loads or fails", async () => {
|
||||
const savedAbbeville: Profile = {
|
||||
...savedHandan,
|
||||
countryCode: "US",
|
||||
provinceCode: "US-AL",
|
||||
cityCode: "110968",
|
||||
districtCode: "",
|
||||
birthPlaceLabel: "美国 · Alabama · Abbeville",
|
||||
birthPlaceType: "city",
|
||||
birthPlaceProvider: "world_location_table",
|
||||
birthPlaceProviderId: "world:US:US-AL:110968",
|
||||
timezoneId: "America/Chicago",
|
||||
latitude: 31.57184,
|
||||
longitude: -85.25049,
|
||||
timezoneOffset: -5,
|
||||
};
|
||||
for (const regions of ["ok", "fail"] as const) {
|
||||
const form = await mountForm(savedAbbeville, { regions });
|
||||
try {
|
||||
// Before the fix the picker reported null while the list loaded: the place was cleared and the picker fell back to China.
|
||||
assert.equal(form.lookups.length, 0, regions);
|
||||
assert.equal(form.state.profile.countryCode, "US", regions);
|
||||
assert.equal(form.state.profile.cityCode, "110968", regions);
|
||||
assert.equal(form.state.profile.timezoneId, "America/Chicago", regions);
|
||||
assert.equal(form.saveDisabled(), false, regions);
|
||||
if (regions === "ok") assert.match(form.status(), /Abbeville.*America\/Chicago · UTC[−-]5/);
|
||||
} finally {
|
||||
await form.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every form that holds the picker holds its submit through the pending scope", () => {
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const fields = read("../src/components/birth-location-fields.tsx");
|
||||
assert.match(fields, /onPendingChange=\{reportPending\}/);
|
||||
// The edit dialogs (people page, chart page) share ChartProfileForm; the onboarding place card has its own form.
|
||||
for (const path of ["../src/components/chart-profile-form.tsx", "../src/components/home-onboarding-shell.tsx"]) {
|
||||
const source = read(path);
|
||||
const scope = source.slice(source.indexOf("<BirthPlacePendingScope>"), source.indexOf("</BirthPlacePendingScope>"));
|
||||
assert.ok(scope.includes("<BirthPlaceSubmitButton"), path);
|
||||
assert.ok(scope.includes(path.includes("chart-profile-form") ? "<ProfileFields" : "<BirthLocationFields"), path);
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url),
|
||||
const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8");
|
||||
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
|
||||
const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8");
|
||||
const reportWorker = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
||||
const plan = readFileSync(new URL("../src/lib/consultation-plan.ts", import.meta.url), "utf8");
|
||||
const routeService = readFileSync(new URL("../src/lib/consultation-route-service.ts", import.meta.url), "utf8");
|
||||
const pythonPlanContract = readFileSync(new URL("../../scripts/consultation_plan_contract.py", import.meta.url), "utf8");
|
||||
@@ -339,8 +340,14 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
|
||||
|
||||
test("defers optional external evidence only for foreground chat", () => {
|
||||
assert.match(workflow, /defer_optional_external_evidence: options\?\.foreground === true/);
|
||||
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/);
|
||||
// 原值: /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/
|
||||
// 新值: 报告带 vedastroEvidenceBudget: "report",仍不是前台对话(不带 foreground)
|
||||
// 原因: BUG-1508,报告等 VedAstro 要有总时限,超时标 blocked 并记当天失败
|
||||
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input, \{ vedastroEvidenceBudget: "report" \}\)/);
|
||||
assert.doesNotMatch(reportsRoute, /foreground:\s*true/);
|
||||
assert.match(workflow, /vedastro_evidence_budget: options\.vedastroEvidenceBudget/);
|
||||
assert.match(reportWorker, /runConsultationWorkflow\(input, \{ signal: context\.signal, vedastroEvidenceBudget: "report" \}\)/);
|
||||
assert.doesNotMatch(reportWorker, /foreground:\s*true/);
|
||||
});
|
||||
|
||||
test("personal Agent owns the Skill and context-bound calculation tool", () => {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
// BUG-1505: a report job whose lease expires on its last attempt is failed by
|
||||
// the recovery sweep, which must also release the reserved credits exactly
|
||||
// once, for every report type, and must not lose a refund that fails.
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
const schemaPassword = "schema-owner-test-password";
|
||||
const serviceRole = ["service_runtime", "service-runtime-test-password"] as const;
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
function windowsMigrationDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "jyotisha-1505-migrations-"));
|
||||
const seen = new Map<string, Buffer>();
|
||||
for (const relative of ["../db/migrations", "../supabase/migrations"]) {
|
||||
const source = fileURLToPath(new URL(relative, import.meta.url));
|
||||
for (const name of readdirSync(source)) {
|
||||
if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue;
|
||||
const bytes = readFileSync(join(source, name));
|
||||
if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue;
|
||||
const previous = seen.get(name);
|
||||
if (previous) {
|
||||
assert.equal(bytes.equals(previous), true, `${name} mirrors must match`);
|
||||
continue;
|
||||
}
|
||||
seen.set(name, bytes);
|
||||
writeFileSync(join(directory, name), bytes);
|
||||
}
|
||||
}
|
||||
assert.ok(seen.has("20261102010000_report_lease_expiry_release.sql"));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function applyMigrations(fixture: PostgresFixture): void {
|
||||
const env = {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword),
|
||||
};
|
||||
const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env });
|
||||
if (first.status === 0) {
|
||||
assert.match(first.stdout, /20261102010000_report_lease_expiry_release\.sql/);
|
||||
return;
|
||||
}
|
||||
const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`;
|
||||
assert.match(detail, /duplicate migration filename/, detail);
|
||||
const directory = windowsMigrationDirectory();
|
||||
try {
|
||||
const second = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, MIGRATIONS_DIRECTORY: directory },
|
||||
});
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
type Seeded = Readonly<{ owner: string; request: string; report: string; job: string }>;
|
||||
|
||||
/**
|
||||
* One user with 10 credits, a 2-credit reservation under the report's request
|
||||
* id, and a generating report whose job is running with an expired lease on
|
||||
* attempt `attempt` of 3.
|
||||
*/
|
||||
function seedExpiredJob(
|
||||
fixture: PostgresFixture,
|
||||
input: Readonly<{ reportType: "personal_full" | "annual_full"; attempt: 1 | 3; featureKey: string }>,
|
||||
): Seeded {
|
||||
const owner = randomUUID();
|
||||
const request = randomUUID();
|
||||
const report = randomUUID();
|
||||
const schemaVersion = input.reportType === "annual_full" ? "annual_report.v1" : "report_document.v2";
|
||||
fixture.psql(`insert into identity.users(id,name,email,email_verified,email_verified_at)
|
||||
values('${owner}','Fictional lease','${owner}@example.invalid',true,now());
|
||||
update public.profiles set credits=10 where id='${owner}';`);
|
||||
assert.equal(
|
||||
fixture.psql(`select success::text || '|' || credits::text from public.authorize_usage('${owner}','${input.featureKey}',null,'${request}',2)`),
|
||||
"true|8",
|
||||
);
|
||||
fixture.psql(`insert into public.personal_reports(id,user_id,request_id,request_fingerprint,report_type,status,
|
||||
schema_version,presentation_mode,requested_themes,depth,skill_name,skill_version,skill_source_commit,skill_snapshot_sha256)
|
||||
values('${report}','${owner}','${request}','${HASH}','${input.reportType}','generating',
|
||||
'${schemaVersion}','default',array['career']::text[],'standard','jyotish-personal-report','1.0.0','${"b".repeat(40)}','${HASH}');`);
|
||||
const job = fixture.psql(`select id from public.personal_report_jobs where user_id='${owner}' and request_id='${request}'`);
|
||||
assert.match(job, /^[0-9a-f-]{36}$/);
|
||||
if (input.attempt === 3) {
|
||||
// Two attempts already spent: the next claim is the last one.
|
||||
fixture.psql(`delete from public.personal_report_jobs where id='${job}';
|
||||
insert into public.personal_report_jobs (
|
||||
id, user_id, request_id, request_fingerprint, status,
|
||||
attempt_count, max_attempts, next_attempt_at, progress_phase,
|
||||
last_error_code, last_error_at
|
||||
) values (
|
||||
'${job}', '${owner}', '${request}', '${HASH}',
|
||||
'retrying', 2, 3, clock_timestamp() - interval '1 second', 'retry_wait',
|
||||
'lease_expired', clock_timestamp()
|
||||
);`);
|
||||
}
|
||||
const claimed = fixture.psqlAs(...serviceRole, `set role service_role;
|
||||
select attempt_count from public.claim_personal_report_job('fictional-worker', 60, '${job}');`);
|
||||
assert.equal(claimed, `SET\n${input.attempt}`);
|
||||
// The worker process died: nobody heartbeats and the lease runs out.
|
||||
fixture.psql(`update public.personal_report_jobs
|
||||
set lease_acquired_at = clock_timestamp() - interval '3 seconds',
|
||||
heartbeat_at = clock_timestamp() - interval '2 seconds',
|
||||
lease_expires_at = clock_timestamp() - interval '1 second'
|
||||
where id='${job}';`);
|
||||
return { owner, request, report, job };
|
||||
}
|
||||
|
||||
function recover(fixture: PostgresFixture): string {
|
||||
return fixture.psqlAs(...serviceRole, "set role service_role; select * from public.recover_expired_personal_report_jobs(10);");
|
||||
}
|
||||
|
||||
function credits(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select credits from public.profiles where id='${seeded.owner}'`);
|
||||
}
|
||||
|
||||
function refunds(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select count(*) from public.credit_transactions
|
||||
where user_id='${seeded.owner}' and request_id='${seeded.request}' and transaction_type='refund'`);
|
||||
}
|
||||
|
||||
function reservation(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select status from public.usage_reservations where user_id='${seeded.owner}' and request_id='${seeded.request}'`);
|
||||
}
|
||||
|
||||
function states(fixture: PostgresFixture, seeded: Seeded): string {
|
||||
return fixture.psql(`select report.status || ':' || coalesce(report.failure_code, '-') || ':' || job.status
|
||||
from public.personal_reports report
|
||||
join public.personal_report_jobs job on job.user_id = report.user_id and job.request_id = report.request_id
|
||||
where report.id='${seeded.report}'`);
|
||||
}
|
||||
|
||||
test("lease-expiry recovery releases a last-attempt report once and never double-refunds", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
|
||||
// Personal and annual reports on their last attempt; a personal report
|
||||
// with attempts left must keep its reservation for the retry.
|
||||
const personal = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
const annual = seedExpiredJob(fixture, { reportType: "annual_full", attempt: 3, featureKey: "annual.full" });
|
||||
const retrying = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 1, featureKey: "report.full" });
|
||||
|
||||
assert.equal(recover(fixture), "SET\n1|2");
|
||||
for (const seeded of [personal, annual]) {
|
||||
assert.equal(states(fixture, seeded), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, seeded), "10");
|
||||
assert.equal(refunds(fixture, seeded), "1");
|
||||
assert.equal(reservation(fixture, seeded), "released");
|
||||
}
|
||||
assert.equal(states(fixture, retrying), "generating:-:retrying");
|
||||
assert.equal(credits(fixture, retrying), "8");
|
||||
assert.equal(reservation(fixture, retrying), "reserved");
|
||||
|
||||
// A second sweep, a late worker release and deleting the failed report all
|
||||
// hit the same idempotency key: no second refund.
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
fixture.psqlAs(...serviceRole, `set role service_role;
|
||||
select * from public.release_usage('${personal.owner}','${personal.request}','late_worker');`);
|
||||
fixture.psql(`delete from public.personal_reports where id='${personal.report}'`);
|
||||
assert.equal(credits(fixture, personal), "10");
|
||||
assert.equal(refunds(fixture, personal), "1");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refund that fails during recovery leaves that job running and does not block the batch", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
const blocked = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
const healthy = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
|
||||
// Simulate the billing write failing for one reservation only.
|
||||
fixture.psql(`create function public.test_1505_block_release() returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.request_id = '${blocked.request}' then raise exception 'fictional_billing_outage'; end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger test_1505_block_release before update on public.usage_reservations
|
||||
for each row execute function public.test_1505_block_release();`);
|
||||
|
||||
assert.equal(recover(fixture), "SET\n0|1");
|
||||
assert.equal(states(fixture, healthy), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, healthy), "10");
|
||||
// The blocked job is not closed with its credits held: still running,
|
||||
// still generating, still reserved, and picked up by the next sweep.
|
||||
assert.equal(states(fixture, blocked), "generating:-:running");
|
||||
assert.equal(credits(fixture, blocked), "8");
|
||||
assert.equal(reservation(fixture, blocked), "reserved");
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
|
||||
fixture.psql(`drop trigger test_1505_block_release on public.usage_reservations;
|
||||
drop function public.test_1505_block_release();`);
|
||||
assert.equal(recover(fixture), "SET\n0|1");
|
||||
assert.equal(states(fixture, blocked), "failed:calculation_unavailable:failed");
|
||||
assert.equal(credits(fixture, blocked), "10");
|
||||
assert.equal(refunds(fixture, blocked), "1");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("a delivered report found by recovery is never refunded", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
const delivered = seedExpiredJob(fixture, { reportType: "personal_full", attempt: 3, featureKey: "report.full" });
|
||||
fixture.psql(`update public.personal_reports set status='ready',completed_at=now(),report_document='{}'::jsonb,
|
||||
calculation_hash='${HASH}',evidence_hash='${HASH}' where id='${delivered.report}';`);
|
||||
|
||||
assert.equal(recover(fixture), "SET\n0|0");
|
||||
assert.equal(states(fixture, delivered), "ready:-:ready");
|
||||
assert.equal(credits(fixture, delivered), "8");
|
||||
assert.equal(refunds(fixture, delivered), "0");
|
||||
assert.equal(reservation(fixture, delivered), "reserved");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -98,6 +98,7 @@ function createHarness(input: Readonly<{
|
||||
loseLeaseBeforeCompletion?: boolean;
|
||||
markReadyError?: PersonalReportJobServiceError;
|
||||
billing?: ReportBillingPort;
|
||||
logObservability?: (event: unknown) => never;
|
||||
}>) {
|
||||
let job = input.job ?? jobRecord();
|
||||
let report = input.report === undefined ? reportRecord() : input.report;
|
||||
@@ -351,6 +352,7 @@ function createHarness(input: Readonly<{
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
...(input.logObservability ? { logObservability: input.logObservability } : {}),
|
||||
now: () => NOW,
|
||||
retryDelayMs: () => 1_000,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -755,6 +757,109 @@ test("a synastry report rejected by the reader gate releases the reservation", a
|
||||
assert.equal(harness.billingCompleteCount, 0);
|
||||
});
|
||||
|
||||
function refundLogSink() {
|
||||
const logged: Array<Record<string, unknown>> = [];
|
||||
const sink = ((event: unknown) => {
|
||||
logged.push(event as Record<string, unknown>);
|
||||
return event as never;
|
||||
}) as (event: unknown) => never;
|
||||
return { logged, sink };
|
||||
}
|
||||
|
||||
// BUG-1505: a refund that fails must not be dropped. The job keeps its lease,
|
||||
// so the expiry sweep owns the outcome and releases on the last attempt.
|
||||
for (const [label, release] of [
|
||||
["returns false", async () => false],
|
||||
["throws", async () => { throw new Error("release rpc down"); }],
|
||||
] as const) {
|
||||
test(`terminal failure whose refund ${label} keeps the lease and logs report_refund_failed`, async () => {
|
||||
const { logged, sink } = refundLogSink();
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
release,
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
logObservability: sink,
|
||||
generate: async () => ({
|
||||
status: "failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason: "agent_output_invalid",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
|
||||
assert.equal(result.outcome, "refund_deferred");
|
||||
assert.deepEqual(harness.billingReleaseReasons, ["report_schema_invalid"]);
|
||||
assert.equal(harness.job.status, "running");
|
||||
assert.ok(harness.job.leaseToken);
|
||||
assert.equal(harness.report?.status, "generating");
|
||||
assert.equal(harness.reportFailureCount, 0);
|
||||
assert.equal(harness.events.some((event) => event.startsWith("report+job:failed:")), false);
|
||||
assert.deepEqual(logged, [{
|
||||
requestId: REQUEST_ID,
|
||||
runId: JOB_ID,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
}]);
|
||||
});
|
||||
}
|
||||
|
||||
test("a refund failure on the last attempt leaves the job to the expiry sweep", async () => {
|
||||
const { logged, sink } = refundLogSink();
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
async release() { return false; },
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
logObservability: sink,
|
||||
job: jobRecord({ status: "retrying", attemptCount: 2, maxAttempts: 3, nextAttemptAt: NOW.toISOString() }),
|
||||
generate: async () => {
|
||||
throw new PersonalReportWorkerError("calculation_unavailable", true);
|
||||
},
|
||||
});
|
||||
|
||||
const first = await harness.worker.tick();
|
||||
assert.equal(first.outcome, "refund_deferred");
|
||||
assert.equal(harness.job.attemptCount, 3);
|
||||
assert.equal(harness.job.status, "running");
|
||||
assert.equal(logged.length, 1);
|
||||
// Nothing closed the job, so after the lease expires the database sweep
|
||||
// fails it and releases in one transaction
|
||||
// (database-report-lease-expiry-release-20261102.test.ts).
|
||||
});
|
||||
|
||||
test("a report already failed elsewhere releases before its job is closed", async () => {
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
async complete() { throw new Error("complete must not run"); },
|
||||
async release() { return true; },
|
||||
};
|
||||
const harness = createHarness({
|
||||
billing,
|
||||
report: reportRecord({ status: "failed", failureCode: "calculation_unavailable" }),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
|
||||
assert.equal(result.outcome, "failed");
|
||||
assert.deepEqual(harness.billingReleaseReasons, ["calculation_unavailable"]);
|
||||
assert.equal(harness.job.status, "failed");
|
||||
assert.equal(harness.generateCount, 0);
|
||||
});
|
||||
|
||||
test("production report release treats already-charged and missing-wallet as settled, anything else as failure", () => {
|
||||
const source = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
||||
const release = source.slice(source.indexOf("release: async ({ userId, requestId, reason })"));
|
||||
const body = release.slice(0, release.indexOf("reserve:"));
|
||||
assert.match(body, /releaseUsage\(admin, userId, requestId, reason\)/);
|
||||
assert.match(body, /released\.success\s*\|\| released\.error_code === "request_completed"\s*\|\| released\.error_code === "profile_missing"/);
|
||||
});
|
||||
|
||||
test("retryable durable generation failure does not release before retry exhaustion", async () => {
|
||||
const billing: ReportBillingPort = {
|
||||
async reserve() { throw new Error("not used"); },
|
||||
|
||||
@@ -526,6 +526,54 @@ test("framework getSkill failure is a controlled retry and then a failed turn",
|
||||
assert.equal(billing.released, 1);
|
||||
});
|
||||
|
||||
// BUG-1505: a failed refund is retried once with the same case-bound request
|
||||
// and then logged with an errorCode, never silently dropped.
|
||||
for (const [label, outcomes, expectedReleases, expectLog] of [
|
||||
["recovers on the retry", [false, true], 2, false],
|
||||
["keeps failing", [false, false], 2, true],
|
||||
["throws", ["throw", "throw"], 2, true],
|
||||
] as const) {
|
||||
test(`failed turn whose refund ${label} is retried and recorded`, async () => {
|
||||
const lines: string[] = [];
|
||||
const originalInfo = console.info;
|
||||
console.info = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); };
|
||||
try {
|
||||
let calls = 0;
|
||||
const { options } = runOptions({
|
||||
billing: {
|
||||
reserve: async () => ({ success: true, status: 200 }),
|
||||
complete: async () => true,
|
||||
release: async () => {
|
||||
const next = outcomes[calls] ?? true;
|
||||
calls += 1;
|
||||
if (next === "throw") throw new Error("release rpc down");
|
||||
return next;
|
||||
},
|
||||
},
|
||||
buildAgent: async () => ({
|
||||
stream: async () => ({ fullStream: (async function* () {})() }),
|
||||
getSkill: async () => null,
|
||||
}) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "skill_not_loaded");
|
||||
assert.equal(calls, expectedReleases);
|
||||
const refundLogs = lines.filter((line) => line.includes("rectification_refund_failed"));
|
||||
assert.equal(refundLogs.length, expectLog ? 1 : 0);
|
||||
if (expectLog) {
|
||||
const payload = JSON.parse(refundLogs[0].slice(refundLogs[0].indexOf("{")));
|
||||
assert.equal(payload.caseId, CASE_ID);
|
||||
assert.equal(payload.errorCode, "rectification_refund_failed");
|
||||
assert.equal(payload.billingSettlementResult, "failed");
|
||||
assert.match(refundLogs[0], /^\[agent-observability\] /);
|
||||
}
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("distinct evidence calls in one natural turn are not mistaken for a repeated tool loop", async () => {
|
||||
const firstEvidenceId = EVIDENCE_ID;
|
||||
const secondEvidenceId = "88888888-8888-4888-8888-888888888888";
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
|
||||
|
||||
// BUG-1505 source pins: the lease-expiry sweep releases the reservation before
|
||||
// it fails a job, stays service_role-only and invoker, and never closes a job
|
||||
// whose release failed. The behaviour runs against Postgres in
|
||||
// database-report-lease-expiry-release-20261102.test.ts (staging gate).
|
||||
|
||||
const migrationsUrl = new URL("../supabase/migrations/", import.meta.url);
|
||||
const migrationName = "20261102010000_report_lease_expiry_release.sql";
|
||||
const migration = readFileSync(new URL(migrationName, migrationsUrl), "utf8");
|
||||
|
||||
function functionBody(): string {
|
||||
const start = migration.indexOf("create or replace function public.recover_expired_personal_report_jobs(");
|
||||
assert.ok(start >= 0, "recovery function missing");
|
||||
const end = migration.indexOf("$$;", start);
|
||||
return migration.slice(start, end);
|
||||
}
|
||||
|
||||
test("the newest definition of the recovery sweep is this migration", () => {
|
||||
const definers = readdirSync(migrationsUrl)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.filter((name) => readFileSync(new URL(name, migrationsUrl), "utf8")
|
||||
.includes("function public.recover_expired_personal_report_jobs("))
|
||||
.sort();
|
||||
assert.equal(definers.at(-1), migrationName);
|
||||
assert.match(migration, /^begin;/);
|
||||
assert.match(migration.trimEnd(), /commit;$/);
|
||||
assert.doesNotMatch(migration, /\bdrop\s+(function|table|trigger|policy)\b/i);
|
||||
});
|
||||
|
||||
test("the sweep keeps its signature, invoker mode, empty search_path and service_role-only grant", () => {
|
||||
const body = functionBody();
|
||||
assert.match(body, /\(\s*p_limit integer default 100\s*\)\s*returns table \(retrying_count integer, failed_count integer\)/);
|
||||
assert.match(body, /language plpgsql\s+security invoker\s+set search_path = ''/);
|
||||
assert.doesNotMatch(migration, /security definer/);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke all on function public\.recover_expired_personal_report_jobs\(integer\) from public, anon, authenticated;/,
|
||||
);
|
||||
assert.match(migration, /grant execute on function public\.recover_expired_personal_report_jobs\(integer\) to service_role;/);
|
||||
assert.doesNotMatch(migration, /grant[^;]*\bto (public|anon|authenticated)\b/);
|
||||
});
|
||||
|
||||
test("every row that ends failed releases under the report request id before the update", () => {
|
||||
const body = functionBody();
|
||||
const release = body.indexOf("from public.release_usage(");
|
||||
const update = body.indexOf("return query");
|
||||
assert.ok(release > 0 && update > release, "release must run before the status update");
|
||||
assert.match(body, /public\.release_usage\(\s*v_candidate\.user_id,\s*v_candidate\.request_id::text,/);
|
||||
// Same rule as the status case: not ready, and failed or out of attempts.
|
||||
assert.match(
|
||||
body,
|
||||
/v_candidate\.report_status <> 'ready'\s+and \(\s*v_candidate\.report_status = 'failed'\s+or v_candidate\.attempt_count >= v_candidate\.max_attempts\s*\)/,
|
||||
);
|
||||
assert.match(body, /when job\.attempt_count < job\.max_attempts then 'retrying'\s+else 'failed'/);
|
||||
// Only rows whose release settled reach the update.
|
||||
assert.match(body, /v_job_ids := pg_catalog\.array_append\(v_job_ids, v_candidate\.id\)/);
|
||||
assert.match(body, /where job\.id = any\(v_job_ids\)/);
|
||||
});
|
||||
|
||||
test("a failed release is caught per row, logged with an errorCode and retried by the next sweep", () => {
|
||||
const body = functionBody();
|
||||
assert.match(body, /exception when others then\s+v_release_success := false;/);
|
||||
assert.match(
|
||||
body,
|
||||
/and v_release_error is distinct from 'request_completed'\s+and v_release_error is distinct from 'profile_missing'/,
|
||||
);
|
||||
assert.match(body, /errorCode=report_refund_failed/);
|
||||
assert.match(body, /raise warning[\s\S]*?continue;/);
|
||||
// Candidate rows are locked job-then-report and skipped when busy, as before.
|
||||
assert.match(body, /order by job\.lease_expires_at, job\.id\s+for update skip locked\s+limit p_limit/);
|
||||
});
|
||||
|
||||
test("release_usage is callable from the sweep: service_role holds EXECUTE and the registry does not withhold it", () => {
|
||||
const billing = readFileSync(new URL("20260806030000_settle_order_usage_authorization.sql", migrationsUrl), "utf8");
|
||||
assert.match(billing, /grant execute on function[^;]*public\.release_usage\(uuid,text,text\)[^;]*to service_role;/);
|
||||
const registry = readFileSync(new URL("20260814010000_immutable_skill_registry.sql", migrationsUrl), "utf8");
|
||||
assert.doesNotMatch(registry, /release_usage/);
|
||||
const later = readdirSync(migrationsUrl)
|
||||
.filter((name) => name.endsWith(".sql") && name > "20260806030000_settle_order_usage_authorization.sql")
|
||||
.map((name) => readFileSync(new URL(name, migrationsUrl), "utf8"));
|
||||
for (const text of later) {
|
||||
assert.doesNotMatch(text, /revoke[^;]*release_usage/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("both refund-failure log events pass the closed observability schema", () => {
|
||||
const ids = {
|
||||
requestId: "22222222-2222-4222-8222-222222222222",
|
||||
runId: "44444444-4444-4444-8444-444444444444",
|
||||
caseId: "66666666-6666-4666-8666-666666666666",
|
||||
};
|
||||
agentObservabilityEventSchema.parse({
|
||||
requestId: ids.requestId,
|
||||
runId: ids.runId,
|
||||
errorCode: "report_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
agentObservabilityEventSchema.parse({
|
||||
caseId: ids.caseId,
|
||||
requestId: ids.requestId,
|
||||
errorCode: "rectification_refund_failed",
|
||||
billingSettlementResult: "failed",
|
||||
});
|
||||
const worker = readFileSync(new URL("../src/lib/personal-report-worker-core.ts", import.meta.url), "utf8");
|
||||
assert.match(worker, /errorCode: "report_refund_failed"/);
|
||||
assert.match(worker, /if \(!\(await releaseBeforeFailure\(job, code\)\)\) return "refund_deferred";/);
|
||||
const finish = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run-finish.ts", import.meta.url), "utf8");
|
||||
assert.match(finish, /errorCode: "rectification_refund_failed"/);
|
||||
assert.doesNotMatch(finish, /await billing\.release\(\);/);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ try:
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
defers_optional_vedastro,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
@@ -83,6 +84,7 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
defers_optional_vedastro,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
@@ -2151,7 +2153,8 @@ def execute_consultation_workflow(
|
||||
question = body.get('question') or ''
|
||||
entry_mode = body.get('entry_mode', 'direct_chart')
|
||||
high_rigor = bool(body.get('return_high_rigor_shape'))
|
||||
defer_optional_external_evidence = bool(body.get('defer_optional_external_evidence'))
|
||||
# A report's VedAstro budget defers like consult, with a longer deadline (BUG-1508).
|
||||
defer_optional_external_evidence = defers_optional_vedastro(body)
|
||||
try:
|
||||
from scripts.three_engine_parity_replay_validator import validate_manifest
|
||||
except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
@@ -2901,6 +2904,21 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload):
|
||||
or birth_payload.get('today')
|
||||
or datetime.utcnow().strftime('%Y-%m-%d')
|
||||
)[:10]
|
||||
try:
|
||||
return _attach_vedastro_main_entry_overview_from(orchestrator, priority, chart_result, modules, birth_payload, reference_date)
|
||||
except Exception as exc: # BUG-1504: the overview is optional evidence; never a 500.
|
||||
modules['vedastro_range_scan_result'] = {
|
||||
'backend': 'vedastro_service_adapter_candidate',
|
||||
'available': False,
|
||||
'status': 'blocked',
|
||||
'reason': 'vedastro_main_entry_overview_error',
|
||||
'error_type': type(exc).__name__,
|
||||
'source_metadata': {'ingestion_profile': 'main_entry_overview', 'reference_date': reference_date},
|
||||
}
|
||||
return chart_result
|
||||
|
||||
|
||||
def _attach_vedastro_main_entry_overview_from(orchestrator, priority, chart_result, modules, birth_payload, reference_date):
|
||||
vedastro_evidence = orchestrator.orchestrate_vedastro_evidence({
|
||||
'year': birth_payload.get('year'),
|
||||
'month': birth_payload.get('month'),
|
||||
@@ -7545,41 +7563,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 +7613,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 +7652,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,11 @@ 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",
|
||||
# Report waits on VedAstro only to its deadline, then blocked + remembered (BUG-1508);
|
||||
# a cut VedAstro response is a network error, /api/chart stays 200 (BUG-1504).
|
||||
"tests/test_vedastro_report_deadline_20261102.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,
|
||||
}
|
||||
@@ -19,11 +19,31 @@ budget <= k * join (k=2) keeps leftover occupancy of an already-running
|
||||
worker from stretching later requests past the join cap. The previous
|
||||
default budget of 8s with a 1.5s join left cancelled-too-late workers
|
||||
holding the two-thread pool for the rest of the 8s.
|
||||
|
||||
Report profile (body ``vedastro_evidence_budget="report"``, gap 2 of
|
||||
docs/operations/outage-behaviour-2026-11-01.md, BUG-1508). A report used to
|
||||
wait synchronously on the main-entry overview and the rectification gate,
|
||||
about 128s when VedAstro hangs, past the frontend's 90s. A report now runs
|
||||
the same deferred session as consultation, with a longer wait so a healthy
|
||||
VedAstro still lands in the report:
|
||||
|
||||
deadline: measured from session start, so it overlaps local compute
|
||||
(default 25s, cap 45s).
|
||||
budget: the per-request timeout already configured for the adapter, never
|
||||
more than k * deadline.
|
||||
|
||||
When the deadline passes while the gateway is still running, the report
|
||||
continues with VedAstro marked ``official_blocked`` /
|
||||
``report_vedastro_deadline_exceeded`` and that is remembered for the chart
|
||||
and UTC day, so the next report answers at once. The worker keeps running;
|
||||
if it later succeeds the positive snapshot wins, if it fails its own failure
|
||||
replaces the deadline entry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
@@ -65,6 +85,13 @@ FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO = 2.0
|
||||
FOREGROUND_VEDASTRO_WORKERS_DEFAULT = 2
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# --- report profile: deadline / budget (change together, see docstring) ---
|
||||
REPORT_VEDASTRO_EVIDENCE_BUDGET = "report"
|
||||
REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT = 25.0
|
||||
REPORT_VEDASTRO_DEADLINE_SECONDS_MAX = 45.0
|
||||
REPORT_VEDASTRO_DEADLINE_EXCEEDED = "report_vedastro_deadline_exceeded"
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_FOREGROUND_VEDASTRO_WORKERS = max(
|
||||
int(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_WORKERS", str(FOREGROUND_VEDASTRO_WORKERS_DEFAULT))),
|
||||
1,
|
||||
@@ -98,6 +125,35 @@ def _foreground_vedastro_budget_seconds() -> float:
|
||||
return min(budget, cap)
|
||||
|
||||
|
||||
def is_report_vedastro_budget(body: dict | None) -> bool:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return str(payload.get("vedastro_evidence_budget") or "").strip().lower() == REPORT_VEDASTRO_EVIDENCE_BUDGET
|
||||
|
||||
|
||||
def defers_optional_vedastro(body: dict | None) -> bool:
|
||||
"""Consult asks with the flag; a report budget implies it (BUG-1508)."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return bool(payload.get("defer_optional_external_evidence")) or is_report_vedastro_budget(payload)
|
||||
|
||||
|
||||
def _report_vedastro_deadline_seconds() -> float:
|
||||
raw = str(os.environ.get("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", str(REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT))).strip()
|
||||
try:
|
||||
return min(max(float(raw), 0.0), REPORT_VEDASTRO_DEADLINE_SECONDS_MAX)
|
||||
except ValueError:
|
||||
return REPORT_VEDASTRO_DEADLINE_SECONDS_DEFAULT
|
||||
|
||||
|
||||
def _report_vedastro_budget_seconds() -> float:
|
||||
"""The adapter's own per-request timeout; a report never raises it."""
|
||||
try:
|
||||
from scripts.vedastro_service_adapter import _timeout_seconds as configured_timeout
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from vedastro_service_adapter import _timeout_seconds as configured_timeout
|
||||
cap = FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * _report_vedastro_deadline_seconds()
|
||||
return min(float(configured_timeout()), cap)
|
||||
|
||||
|
||||
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
|
||||
packet = {
|
||||
"scope": "vedastro_gateway_run",
|
||||
@@ -110,11 +166,18 @@ def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None)
|
||||
return packet
|
||||
|
||||
|
||||
def _run_foreground_vedastro_gateway(handler, body: dict, cancel_event: threading.Event | None = None) -> dict:
|
||||
def _run_foreground_vedastro_gateway(
|
||||
handler,
|
||||
body: dict,
|
||||
cancel_event: threading.Event | None = None,
|
||||
*,
|
||||
budget_seconds: float | None = None,
|
||||
) -> dict:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
budget = _foreground_vedastro_budget_seconds() if budget_seconds is None else budget_seconds
|
||||
try:
|
||||
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
|
||||
with temporary_timeout_seconds(budget):
|
||||
result = handler._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
@@ -150,8 +213,9 @@ def _run_foreground_vedastro_gateway_and_store(
|
||||
handler,
|
||||
body: dict,
|
||||
cancel_event: threading.Event | None = None,
|
||||
budget_seconds: float | None = None,
|
||||
) -> dict:
|
||||
result = _run_foreground_vedastro_gateway(handler, body, cancel_event)
|
||||
result = _run_foreground_vedastro_gateway(handler, body, cancel_event, budget_seconds=budget_seconds)
|
||||
# A run cancelled before it started never asked VedAstro: nothing to remember.
|
||||
if not (cancel_event is not None and cancel_event.is_set() and result.get("official_closure_reason") == "foreground_optional_evidence_timeout"):
|
||||
_remember_gateway_outcome(body, result)
|
||||
@@ -198,8 +262,38 @@ class ForegroundVedastroSession:
|
||||
self.future = None
|
||||
self.cancel_event = threading.Event()
|
||||
self.meta: dict[str, Any] = {}
|
||||
self.report = is_report_vedastro_budget(self.body)
|
||||
self.started_at = time.monotonic()
|
||||
|
||||
def _budget_seconds(self) -> float | None:
|
||||
return _report_vedastro_budget_seconds() if self.report else None
|
||||
|
||||
def _join_seconds(self) -> float:
|
||||
if not self.report:
|
||||
return _foreground_vedastro_join_seconds()
|
||||
return max(self.started_at + _report_vedastro_deadline_seconds() - time.monotonic(), 0.0)
|
||||
|
||||
def _report_deadline_exceeded(self, result: dict) -> dict:
|
||||
"""A report gave up on a gateway that is still running: remember it for today."""
|
||||
future = self.future
|
||||
if (
|
||||
not self.report
|
||||
or result.get("official_closure_reason") != "foreground_optional_evidence_timeout"
|
||||
or future is None
|
||||
or future.cancelled()
|
||||
or future.done()
|
||||
):
|
||||
return result
|
||||
packet = _blocked_foreground_vedastro(reason=REPORT_VEDASTRO_DEADLINE_EXCEEDED)
|
||||
packet["deadline_seconds"] = _report_vedastro_deadline_seconds()
|
||||
try:
|
||||
store_negative_snapshot(self.body, packet)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return packet
|
||||
|
||||
def start(self) -> "ForegroundVedastroSession":
|
||||
self.started_at = time.monotonic()
|
||||
today = official_snapshot_reference_date(self.body)
|
||||
hit = lookup_snapshot(self.body, today=today)
|
||||
if hit is not None:
|
||||
@@ -221,6 +315,7 @@ class ForegroundVedastroSession:
|
||||
self.handler,
|
||||
refresh_body,
|
||||
None,
|
||||
self._budget_seconds(),
|
||||
)
|
||||
self.cached_gateway = gateway
|
||||
self.meta = {
|
||||
@@ -253,6 +348,7 @@ class ForegroundVedastroSession:
|
||||
self.handler,
|
||||
dict(self.body),
|
||||
self.cancel_event,
|
||||
self._budget_seconds(),
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -260,11 +356,12 @@ class ForegroundVedastroSession:
|
||||
if self.cached_gateway is not None:
|
||||
return self.cached_gateway
|
||||
if self.defer:
|
||||
return _join_foreground_vedastro(
|
||||
result = _join_foreground_vedastro(
|
||||
self.future,
|
||||
timeout=_foreground_vedastro_join_seconds(),
|
||||
timeout=self._join_seconds(),
|
||||
cancel_event=self.cancel_event,
|
||||
)
|
||||
return self._report_deadline_exceeded(result)
|
||||
try:
|
||||
result = self.handler._compute_vedastro_gateway_run(self.body)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -13,6 +13,7 @@ and remains ``official_blocked``.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -155,7 +156,8 @@ def probe_calculate_health(*, opener: Any | None = None, timeout: float = 8) ->
|
||||
"endpoint": API,
|
||||
"transport": "rest",
|
||||
}
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as error:
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, http.client.HTTPException) as error:
|
||||
# HTTPException: IncompleteRead and friends when the body is cut (BUG-1504).
|
||||
return {
|
||||
"status": "official_blocked",
|
||||
"reason": type(error).__name__,
|
||||
|
||||
@@ -14,6 +14,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2016,6 +2017,12 @@ def _acquire_free_tier_slot(request_url: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# A connection cut while reading headers or the body. urllib only wraps errors
|
||||
# raised while sending, so these reach the caller raw (BUG-1504). RemoteDisconnected
|
||||
# is both an HTTPException and a ConnectionResetError.
|
||||
_TRANSPORT_READ_ERRORS = (http.client.HTTPException, ConnectionError, ssl.SSLError)
|
||||
|
||||
|
||||
def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] | str:
|
||||
request_url, headers, vedastro_payload = _build_live_request(endpoint, request_preview)
|
||||
req = request.Request(
|
||||
@@ -2024,9 +2031,15 @@ def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any]
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=_timeout_seconds()) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
try:
|
||||
with request.urlopen(req, timeout=_timeout_seconds()) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except error.URLError:
|
||||
raise
|
||||
except _TRANSPORT_READ_ERRORS as exc:
|
||||
# Every caller already turns URLError into network_error.
|
||||
raise error.URLError(f"response_read_failed:{type(exc).__name__}") from exc
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _retry_status_codes() -> set[int]:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,344 @@
|
||||
"""BUG-1508 and BUG-1504: VedAstro can no longer stall or break a report.
|
||||
|
||||
BUG-1508 (outage audit gap 2): a report now waits for VedAstro only up to its
|
||||
deadline, then goes on with VedAstro marked blocked and remembers that for the
|
||||
day, so the next report does not wait again.
|
||||
|
||||
BUG-1504: a response cut while it is being read is a network error, like the
|
||||
other transport failures, and the main-entry overview never turns into a 500.
|
||||
|
||||
VedAstro is always a stub here; nothing leaves the machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import io
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib import error
|
||||
|
||||
import pytest
|
||||
|
||||
import scripts.vedastro_foreground as foreground
|
||||
import scripts.vedastro_service_adapter as adapter
|
||||
from scripts.jyotish_api_server import JyotishComputeService
|
||||
from scripts.vedastro_consultation_snapshot import lookup_negative_snapshot
|
||||
from scripts.vedastro_rest_bridge import probe_calculate_health
|
||||
from scripts.vedastro_runtime_context import timeout_override_seconds
|
||||
from tests.test_api_heavy_compute_gate import _PostCaptureHandler
|
||||
|
||||
TODAY = "2026-11-02"
|
||||
BIRTH = {"year": 1990, "month": 6, "day": 15, "hour": 10, "minute": 20, "lat": 31.2, "lon": 121.5, "tz": 8,
|
||||
"ayanamsa": "lahiri", "today": TODAY}
|
||||
REPORT = {**BIRTH, "vedastro_evidence_budget": "report"}
|
||||
BLOCKED = {"scope": "vedastro_gateway_run", "status": "official_blocked", "official_closure_state": "official_blocked",
|
||||
"official_closure_reason": "official_raw_response_missing"}
|
||||
VERIFIED = {"scope": "vedastro_gateway_run", "status": "ok", "official_closure_state": "official_verified",
|
||||
"official_closure_reason": "official_raw_response_present", "official_raw_response": {"ok": True}}
|
||||
|
||||
|
||||
class Gateway:
|
||||
"""Stands in for the handler's gateway call; `hang` blocks until released."""
|
||||
|
||||
def __init__(self, result=None, *, hang=False, raises=None, delay=0.0):
|
||||
self.result, self.hang, self.raises, self.delay = result, hang, raises, delay
|
||||
self.calls = 0
|
||||
self.release = threading.Event()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def _compute_vedastro_gateway_run(self, body):
|
||||
self.calls += 1
|
||||
self.timeouts.append(timeout_override_seconds())
|
||||
if self.hang:
|
||||
self.release.wait(5)
|
||||
time.sleep(self.delay)
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return dict(self.result or BLOCKED)
|
||||
|
||||
|
||||
def _report(handler, body=REPORT, *, pause=0.0):
|
||||
started = time.perf_counter()
|
||||
session = foreground.start_foreground_vedastro(
|
||||
handler, body, defer_optional_external_evidence=foreground.defers_optional_vedastro(body),
|
||||
)
|
||||
time.sleep(pause) # local compute runs here in the real workflow
|
||||
gateway = foreground.finish_foreground_vedastro(session)
|
||||
return gateway, time.perf_counter() - started, session.meta
|
||||
|
||||
|
||||
def _drain():
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and not foreground._FOREGROUND_VEDASTRO_EXECUTOR._work_queue.empty():
|
||||
time.sleep(0.01)
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_deadline(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "0.4")
|
||||
|
||||
|
||||
# ---- BUG-1508: deadline, blocked, remembered --------------------------------
|
||||
|
||||
|
||||
def test_a_report_budget_defers_and_a_plain_request_still_does_not():
|
||||
assert foreground.defers_optional_vedastro(REPORT)
|
||||
assert foreground.defers_optional_vedastro({**BIRTH, "defer_optional_external_evidence": True})
|
||||
assert not foreground.defers_optional_vedastro(BIRTH)
|
||||
|
||||
|
||||
def test_a_hung_vedastro_stops_the_report_at_its_deadline_and_the_next_report_does_not_wait(short_deadline):
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
gateway, seconds, meta = _report(hung)
|
||||
assert meta["freshness"] == "miss"
|
||||
assert 0.3 <= seconds < 0.9, seconds
|
||||
assert gateway["official_closure_state"] == "official_blocked"
|
||||
assert gateway["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
remembered = lookup_negative_snapshot(REPORT, today=TODAY)
|
||||
assert remembered["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
|
||||
again, again_seconds, again_meta = _report(hung)
|
||||
assert again_seconds < 0.15, again_seconds
|
||||
assert again_meta["freshness"] == "negative"
|
||||
assert again == gateway
|
||||
assert hung.calls == 1, "the second report did not ask VedAstro again"
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_the_deadline_counts_from_the_start_so_local_compute_overlaps_it(short_deadline):
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
_, seconds, _ = _report(hung, pause=0.3)
|
||||
assert seconds < 0.7, f"waited the whole deadline again after local compute: {seconds}"
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_a_gateway_that_finishes_after_the_deadline_replaces_the_deadline_entry(short_deadline):
|
||||
late = Gateway(VERIFIED, hang=True)
|
||||
gateway, _, _ = _report(late)
|
||||
assert gateway["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
late.release.set()
|
||||
_drain()
|
||||
again, _, meta = _report(Gateway(BLOCKED))
|
||||
assert meta["freshness"] == "fresh", "a later success wins over the deadline entry"
|
||||
assert again["official_closure_state"] == "official_verified"
|
||||
|
||||
|
||||
def test_a_fast_5xx_failure_returns_at_once_and_is_remembered(short_deadline):
|
||||
failing = Gateway({**BLOCKED, "official_closure_reason": "http_error:503"})
|
||||
gateway, seconds, _ = _report(failing)
|
||||
assert seconds < 0.3 and gateway["official_closure_reason"] == "http_error:503"
|
||||
_drain()
|
||||
again, _, meta = _report(failing)
|
||||
assert failing.calls == 1 and meta["freshness"] == "negative" and again == gateway
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc", [http.client.IncompleteRead(b"{", 10), ConnectionResetError(104, "reset")])
|
||||
def test_a_gateway_that_raises_a_read_error_is_blocked_and_remembered(short_deadline, exc):
|
||||
broken = Gateway(raises=exc)
|
||||
gateway, _, _ = _report(broken)
|
||||
assert gateway["official_closure_state"] == "official_blocked"
|
||||
assert gateway["error_type"] == type(exc).__name__
|
||||
_drain()
|
||||
assert lookup_negative_snapshot(REPORT, today=TODAY)["error_type"] == type(exc).__name__
|
||||
|
||||
|
||||
def test_a_healthy_vedastro_inside_the_deadline_is_used(short_deadline):
|
||||
healthy = Gateway(VERIFIED, delay=0.05)
|
||||
gateway, _, _ = _report(healthy)
|
||||
assert gateway == VERIFIED
|
||||
assert lookup_negative_snapshot(REPORT, today=TODAY) is None
|
||||
|
||||
|
||||
def test_a_report_keeps_the_configured_request_timeout_and_never_raises_it(monkeypatch):
|
||||
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "20")
|
||||
seen = Gateway(VERIFIED)
|
||||
_report(seen)
|
||||
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "120")
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "25")
|
||||
_report(Gateway(VERIFIED), body={**REPORT, "today": "2026-11-03"})
|
||||
_drain()
|
||||
assert seen.timeouts == [20.0]
|
||||
assert foreground._report_vedastro_budget_seconds() == 50.0, "capped at k * deadline"
|
||||
|
||||
|
||||
def test_the_deadline_has_a_cap_well_inside_the_frontend_wait(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_REPORT_VEDASTRO_DEADLINE_SECONDS", "600")
|
||||
assert foreground._report_vedastro_deadline_seconds() == foreground.REPORT_VEDASTRO_DEADLINE_SECONDS_MAX
|
||||
assert foreground.REPORT_VEDASTRO_DEADLINE_SECONDS_MAX <= 45, "frontend waits 90 s per workflow call"
|
||||
|
||||
|
||||
def test_a_consultation_join_timeout_is_still_not_remembered(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "0.05")
|
||||
hung = Gateway(hang=True)
|
||||
try:
|
||||
gateway, _, _ = _report(hung, body={**BIRTH, "defer_optional_external_evidence": True})
|
||||
assert gateway["official_closure_reason"] == "foreground_optional_evidence_timeout"
|
||||
assert lookup_negative_snapshot(BIRTH, today=TODAY) is None
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
def test_the_report_workflow_skips_the_overview_and_marks_vedastro_unavailable(short_deadline, monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
hung = Gateway(hang=True)
|
||||
service = JyotishComputeService()
|
||||
monkeypatch.setattr(JyotishComputeService, "_compute_vedastro_gateway_run",
|
||||
lambda self, body: hung._compute_vedastro_gateway_run(body))
|
||||
|
||||
def no_overview(*args, **kwargs):
|
||||
raise AssertionError("a report must not run the synchronous main-entry overview")
|
||||
|
||||
monkeypatch.setattr("scripts.jyotish_api_server._attach_vedastro_main_entry_overview", no_overview)
|
||||
body = {**REPORT, "question": "请为个人报告计算 career 主题证据", "theme": ["career"], "entry_mode": "direct_chart"}
|
||||
try:
|
||||
result = service._compute_consultation_workflow(dict(body))
|
||||
sections = result["machine_evidence_packet"]["sections"]
|
||||
assert sections["external_oracle_status"]["status"] == "official_blocked"
|
||||
assert sections["vedastro_official_raw_response"]["status"] == "missing"
|
||||
cross_check = result["consumer_context"]["vedastro_cross_check"]
|
||||
assert cross_check["official_closure_reason"] == foreground.REPORT_VEDASTRO_DEADLINE_EXCEEDED
|
||||
|
||||
started = time.perf_counter()
|
||||
service._compute_consultation_workflow(dict(body))
|
||||
assert hung.calls == 1, "the second report answered from the remembered failure"
|
||||
assert time.perf_counter() - started < 30
|
||||
finally:
|
||||
hung.release.set()
|
||||
_drain()
|
||||
|
||||
|
||||
# ---- BUG-1504: truncated responses are network errors -----------------------
|
||||
|
||||
|
||||
class _CutResponse:
|
||||
def __init__(self, exc):
|
||||
self.exc = exc
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
raise self.exc
|
||||
|
||||
|
||||
def _urlopen_raising(exc, *, on_read):
|
||||
def fake(req, timeout=None, *args, **kwargs):
|
||||
if on_read:
|
||||
return _CutResponse(exc)
|
||||
raise exc
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
READ_ERRORS = [
|
||||
pytest.param(http.client.IncompleteRead(b'{"Status":', 200), True, id="incomplete-read"),
|
||||
pytest.param(ConnectionResetError(104, "Connection reset by peer"), True, id="reset-on-read"),
|
||||
pytest.param(ConnectionResetError(104, "Connection reset by peer"), False, id="reset-on-headers"),
|
||||
pytest.param(http.client.RemoteDisconnected("closed"), False, id="remote-disconnected"),
|
||||
pytest.param(ssl.SSLError("bad record mac"), True, id="ssl"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_network(monkeypatch):
|
||||
monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://oracle.stub.invalid/api")
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1")
|
||||
monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "0")
|
||||
monkeypatch.setenv("VEDASTRO_RETRY_BACKOFF_SECONDS", "0")
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
empty = {"available": False, "status": "stubbed", "snapshot_sections": {}, "section_statuses": {}}
|
||||
monkeypatch.setattr(adapter, "_try_official_capability_runner_snapshot_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_try_official_full_capability_catalog_bundle", lambda case: dict(empty))
|
||||
monkeypatch.setattr(adapter, "_load_cached_payload", lambda key: (None, None))
|
||||
monkeypatch.setattr(adapter, "_write_artifact", lambda result: None)
|
||||
|
||||
|
||||
def _case():
|
||||
return {**BIRTH, "case_id": "stub", "reference_date": TODAY}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc,on_read", READ_ERRORS)
|
||||
def test_a_cut_response_is_a_network_error_in_the_snapshot_and_the_range_scan(stub_network, monkeypatch, exc, on_read):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(exc, on_read=on_read))
|
||||
with pytest.raises(error.URLError):
|
||||
adapter._post_json("https://oracle.stub.invalid/api", {"operation": "official_full_snapshot",
|
||||
"section": "x", "official_request_profile": {}})
|
||||
snapshot = adapter._run_official_full_snapshot_case(_case(), case_id="stub")
|
||||
statuses = [value for value in snapshot["section_statuses"].values() if isinstance(value, str)]
|
||||
expected = f"network_error:response_read_failed:{type(exc).__name__}"
|
||||
assert expected in statuses, statuses
|
||||
assert "ok" not in statuses and snapshot["status"] != "ok"
|
||||
scan = adapter._run_range_scan_case(_case(), "career", "2026-11-01", "2026-11-30")
|
||||
assert scan["status"] == "network_error" and scan["available"] is False
|
||||
|
||||
|
||||
def test_a_5xx_is_still_an_http_error(stub_network, monkeypatch):
|
||||
def fake(req, timeout=None, *args, **kwargs):
|
||||
raise error.HTTPError(req.full_url, 503, "Service Unavailable", {}, io.BytesIO(b""))
|
||||
|
||||
monkeypatch.setattr(adapter.request, "urlopen", fake)
|
||||
snapshot = adapter._run_official_full_snapshot_case(_case(), case_id="stub")
|
||||
assert any(value == "http_error:503" for value in snapshot["section_statuses"].values())
|
||||
|
||||
|
||||
def test_a_read_timeout_is_still_a_timeout(stub_network, monkeypatch):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(socket.timeout("timed out"), on_read=True))
|
||||
scan = adapter._run_range_scan_case(_case(), "career", "2026-11-01", "2026-11-30")
|
||||
assert scan["status"] == "timeout"
|
||||
|
||||
|
||||
def _chart_request():
|
||||
return _PostCaptureHandler("/api/chart", {**BIRTH, "date": "1990-06-15", "time": "10:20"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc,on_read", READ_ERRORS[:2])
|
||||
def test_api_chart_with_the_overview_returns_200_when_vedastro_cuts_the_response(stub_network, monkeypatch, exc, on_read):
|
||||
monkeypatch.setattr(adapter.request, "urlopen", _urlopen_raising(exc, on_read=on_read))
|
||||
handler = _chart_request()
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200, handler.wfile.getvalue()[:300]
|
||||
overview = handler.payload()["modules"]["vedastro_range_scan_result"]
|
||||
assert overview["status"] != "ok"
|
||||
assert overview["official_full_snapshot"]["status"] != "ok"
|
||||
|
||||
|
||||
def test_api_chart_overview_attach_fails_soft_on_any_error(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
|
||||
from scripts.jyotish_api_server import _load_local_module
|
||||
|
||||
orchestrator = _load_local_module("vedastro_evidence_orchestrator")
|
||||
|
||||
def cut(*args, **kwargs):
|
||||
raise http.client.IncompleteRead(b"", 5)
|
||||
|
||||
monkeypatch.setattr(orchestrator, "orchestrate_vedastro_evidence", cut)
|
||||
handler = _chart_request()
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200, handler.wfile.getvalue()[:300]
|
||||
overview = handler.payload()["modules"]["vedastro_range_scan_result"]
|
||||
assert overview["status"] == "blocked"
|
||||
assert overview["reason"] == "vedastro_main_entry_overview_error"
|
||||
assert overview["error_type"] == "IncompleteRead"
|
||||
|
||||
|
||||
def test_the_rest_health_probe_blocks_on_a_cut_body():
|
||||
def opener(req, timeout=None):
|
||||
return _CutResponse(http.client.IncompleteRead(b"{", 9))
|
||||
|
||||
result = probe_calculate_health(opener=opener)
|
||||
assert result["status"] == "official_blocked" and result["reason"] == "IncompleteRead"
|
||||
Reference in New Issue
Block a user