Compare commits

..
Author SHA1 Message Date
Jesse_ChenandClaude Opus 5.5 29cf4584f6 docs(research): consult preset prompts merged with the astrologer review (issue #151)
All 12 domains and 48 prompts kept; every review note applied, the two
"delete and replace" items use the reviewer's replacement text, and each
domain gains its reviewed candidate question (60 total). Menu text only;
backend execution rules from the review are tracked separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:18:10 +08:00
Claude 230a493fcd Merge BUG-1504/BUG-1508 VedAstro report deadline
Independent Staging Quality Gate / validate (push) Successful in 19m22s
Independent Staging Quality Gate / publish (push) Successful in 3m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y

# Conflicts:
#	CHANGELOG.md
#	docs/operations/outage-behaviour-2026-11-01.md
2026-11-02 07:50:47 +08:00
ClaudeandClaude Opus 5.5 bc4a6c2b49 fix(report): bound VedAstro wait in report generation (BUG-1508, BUG-1504)
Reports send vedastro_evidence_budget=report. The API defers like consult
(no sync main-entry overview, cache-only gate, gateway in the foreground
pool) with a 25 s deadline from session start (cap 45 s). On overrun the
report continues with VedAstro official_blocked /
report_vedastro_deadline_exceeded and the failure is stored in the day's
negative snapshot cache. The main-entry overview attach now fails soft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 07:50:03 +08:00
ClaudeandClaude Opus 5.5 87765d602c fix(vedastro): cut responses are network errors, not 500 (BUG-1504)
_post_json turns IncompleteRead, ConnectionResetError, RemoteDisconnected and
ssl.SSLError raised while reading into URLError, so snapshot dispatch, range
scan and external technique evidence record network_error. The REST health
probe also catches http.client.HTTPException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 07:50:02 +08:00
Claude c881dfb4ae Merge BUG-1506 birthplace fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y

# Conflicts:
#	CHANGELOG.md
2026-11-02 01:08:26 +08:00
ClaudeandClaude Opus 5.5 6721d320d8 fix(reports): release credits when lease recovery fails a report (BUG-1505)
recover_expired_personal_report_jobs now releases the reservation (by the
report's request_id, idempotent with the worker and the delete trigger)
before it fails a job; a release that raises or errors leaves that row
running for the next sweep and logs errorCode=report_refund_failed.
Migration 20261102010000_report_lease_expiry_release.sql replaces only the
function (invoker, empty search_path, service_role only).

The report worker no longer closes a failed job when its refund fails: it
logs report_refund_failed and leaves the lease to the recovery sweep.
Rectification retries a failed release once with the same case request id
and logs rectification_refund_failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 01:06:19 +08:00
ClaudeandClaude Opus 5.5 4ab81821cd fix(profile): keep the saved birth place when the timezone lookup fails (BUG-1506)
The birth place picker reported null on a failed timezone lookup and on a
half-chosen level; BirthLocationFields then cleared the place and the picker
reset its draft. Editing a saved person's date, time or place during a
timezone outage silently erased the birthplace with no error or retry.
The same path reset the picker when changing province/city/country on edit
and when opening an overseas person while the region list loaded.

- The picker only reports a freshly resolved place; failures and unfinished
  choices keep the previous value and report "pending" instead.
- BirthPlacePendingScope + BirthPlaceSubmitButton disable 保存 (edit dialogs)
  and 确定 (onboarding) while pending.
- Failure copy for a person with a place: 「时区这会儿没查到,原来的出生地还在,
  查到后才能保存。」 with 重试; new profiles keep the existing copy.
- Track the moment the stored offset was resolved for, so going back to the
  original date after a successful lookup re-resolves instead of saving a
  stale offset; ignore late results from aborted lookups.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:23:58 +08:00
42 changed files with 2725 additions and 240 deletions
@@ -97,6 +97,8 @@ jobs:
-r requirements.txt -r requirements-dev.txt playwright
python -m playwright install --with-deps chromium
npm ci --prefix frontend
# Production refuses placeholder operator details and draft terms / privacy text (#119 item 9).
(cd frontend && JYOTISHA_REQUIRE_LEGAL_ENTITY=1 npx tsx --test tests/legal-consent-20260930.test.tsx)
python scripts/run_quality_gate.py --profile release
python -m pytest -q --maxfail=1
python tests/run_all.py
+29
View File
@@ -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 — 年运报告:从「我的报告」直接打开,写好一节出一节
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
+18
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,5 +28,6 @@
}
www.jyotisha.chat {
import security_headers
redir https://jyotisha.chat{uri} permanent
}
+19
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,6 +28,7 @@
}
https://admin.jyotisha.chat {
import security_headers
encode zstd gzip
@root path /
redir @root /admin 308
@@ -18,5 +36,6 @@ https://admin.jyotisha.chat {
}
https://www.jyotisha.chat {
import security_headers
redir https://jyotisha.chat{uri} 308
}
+18
View File
@@ -1,4 +1,21 @@
# Security response headers on every site (issue #119, checklist item 33).
# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains
# because the subdomains are listed one by one.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
-Server
-X-Powered-By
}
}
{$SITE_ADDRESS:https://staging.jyotisha.chat} {
import security_headers
encode zstd gzip
@htmlDocuments path / /login
@@ -11,6 +28,7 @@
}
https://admin.staging.jyotisha.chat {
import security_headers
encode zstd gzip
@root path /
redir @root /admin 308
+1 -1
View File
@@ -138,7 +138,7 @@ Before a production release, run the legal-consent test with the production flag
cd frontend && JYOTISHA_REQUIRE_LEGAL_ENTITY=1 npx tsx --test tests/legal-consent-20260930.test.tsx
```
It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operator details or the terms / privacy text is still marked draft. The workflows are not changed for this (they are product-owner territory); the step is manual until the product owner wires it in.
It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operator details or the terms / privacy text is still marked draft. Since 2026-11-01 (product owner approved, #119 item 9) the manual release gate `.gitea/workflows/release-quality-gate.yml` runs this check right after its frontend install, so a production release is refused until the details are filled in and the draft markers are removed.
## Manual production deployment with Gitea Actions
+109 -9
View File
@@ -19935,19 +19935,19 @@
## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500
- 状态:investigating
- 状态:resolved
- 首次发现:2026-10-27(做 issue #114 时调接口发现)
- 最近更新:2026-10-27
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。
- 最近更新:2026-11-01
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。进程内的两处调用(咨询的 `_attach_local_consultation_layers`、专题的 `_derive_thematic_evidence`)只在图里还没有 `modules.shadbala` 时才走这里,以前会被各自的 `except` 记成 `unavailable` / `warning`,不报 500。
- 用户现象:接口返回 `ERR_INTERNAL`。
- 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。
- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。
- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。
- 防复发:待修复单补接口测试。
- 相关记录:issue #114。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。整份文件只有这一处(`ruff --select F821` 实测只报 7564 行)。只带星体经度的旧测试走不到这条分支,所以一直没拦住。
- 修复:新建 `scripts/shadbala_service/api.py`,模块顶部 `import swisseph as swe`,把 `_compute_shadbala` 的出生时刻分支、`_shadbala_rule_variants`、`_compute_shadbala_advanced_layer` 原样移过去。主文件的 `_compute_shadbala` 只转发,并把不支持的 ayanamsa 转成 400。主文件四空格方法 217 → 215,`tests/test_api_server_growth_contract.py` 基线同步降到 215。
- 验证:`tests/test_shadbala_service_api.py` 在本机端口起真实 `JyotishAPIHandler`,POST 金盘出生资料(1990-06-15 12:00 北京,raman,虚构)→ 200;`method_variants` 为精确 Kala / Chesta;七星 Dig Bala 等于同一时刻 `build_shadbala_context` 的宫中点算出的值。只带星体时仍等于 `chart-insight-golden.json` 的 `chart_modules_shadbala`。坏 ayanamsa、缺日月 → 400。把主文件换回修复前版本,同一测试得到 500 和 `NameError: name 'swe' is not defined`。
- 防复发:上面的接口测试已加进 `scripts/run_quality_gate.py` 的 quick 清单;同一文件里还对 `jyotish_api_server.py`、`offline_compute_mixins.py`、`scripts/shadbala_service/` 跑 `ruff --select F821`,再有用了没导入的名字就失败。
- 相关记录:issue #114;Gitea #119 第 46 项。
- 复发自:无
- 修复版本:分支 `codex/rectification-d60-two-way-20261028`。
- 修复版本:分支 `codex/outage-shadbala-119`。
## BUG-1464 | 存过别人的账户进首页即「页面出错了」
@@ -20570,3 +20570,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 条 |
+1
View File
@@ -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
+1 -1
View File
@@ -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} />;
}
+45 -17
View File
@@ -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
+36 -33
View File
@@ -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: "三引擎运行时已闭环",
+62 -37
View File
@@ -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,
+10 -2
View File
@@ -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;
+26 -10
View File
@@ -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]);
}
});
+25 -95
View File
@@ -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:
+5
View File
@@ -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 = [
+5
View File
@@ -0,0 +1,5 @@
"""Shadbala endpoint service (BUG-1463).
- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for
``/api/shadbala`` and the in-process callers of ``_compute_shadbala``.
"""
+154
View File
@@ -0,0 +1,154 @@
"""`/api/shadbala`: the six-fold strength core plus the advanced evidence layer.
Moved out of ``JyotishAPIHandler`` for BUG-1463. The handler method used
``swe.julday`` with no ``swe`` in scope, so every request carrying
year/month/day/lat/lon failed with ``NameError`` (HTTP 500). This module
imports Swiss Ephemeris itself; the handler only forwards the body.
The handler is passed in for its existing body parsers (``_get_float``,
``_parse_timezone`` ...). They raise the server's ``BadRequest`` and that
propagates unchanged. This module never imports the HTTP server.
"""
from __future__ import annotations
import logging
from typing import Any
import swisseph as swe
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
BIRTH_KEYS = ("year", "month", "day", "lat", "lon")
logger = logging.getLogger(__name__)
class ShadbalaInputError(ValueError):
"""The request names an ayanamsa the engine does not support."""
def _ayanamsa(body: dict[str, Any]) -> str:
"""Same reading as the server's ``_request_ayanamsa(body)``."""
raw = body.get("ayanamsa") or body.get("ayanamsa_name") or body.get("ayanamsa_policy")
if raw in (None, ""):
return DEFAULT_AYANAMSA_NAME
try:
return normalize_ayanamsa_name(raw)
except UnsupportedAyanamsaError as exc:
raise ShadbalaInputError(str(exc)) from exc
def birth_context(handler: Any, body: dict[str, Any], birth_hour: float, birth_minute: float, birth_second: float) -> dict[str, Any] | None:
"""Precise Swiss Ephemeris context when the body carries the full birth moment."""
if not all(key in body for key in BIRTH_KEYS):
return None
import shadbala
year = handler._get_int(body, "year", 1990, 1800, 2400)
month = handler._get_int(body, "month", 6, 1, 12)
day = handler._get_int(body, "day", 15, 1, 31)
lat = handler._get_float(body, "lat", 0, -90, 90)
lon = handler._get_float(body, "lon", 0, -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
jd_ut = swe.julday(year, month, day, hour_decimal - tz)
return shadbala.build_shadbala_context(jd_ut, lat, lon, _ayanamsa(body), tz)
def rule_variants(advanced_enabled: bool) -> dict[str, Any]:
return {
"selected": ["core_sixfold", "advanced_evidence"] if advanced_enabled else ["core_sixfold"],
"available": [
{"key": "core_sixfold", "label": "六重力量主算法", "status": "active", "source": "scripts/shadbala.py"},
{"key": "advanced_evidence", "label": "Kala/Yuddha/Sputa 增强证据", "status": "active" if advanced_enabled else "unavailable", "source": "scripts/shadbala_advanced.py"},
],
"boundary": "增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。",
}
def advanced_layer(handler: Any, body: dict[str, Any], planets: dict[str, Any], base_result: Any) -> dict[str, Any]:
"""Kala sub-items, Yuddha and Sputa Drishti evidence; ``{}`` when it cannot be built."""
from datetime import datetime
try:
import shadbala_advanced as advanced_mod
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
year = handler._get_int(body, "year", datetime.now().year, 1800, 2400)
month = handler._get_int(body, "month", 1, 1, 12)
day = handler._get_int(body, "day", 1, 1, 31)
lat = handler._get_float(body, "lat", body.get("birth_lat", 0), -90, 90)
lon = handler._get_float(body, "lon", body.get("birth_lon", 0), -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
solar_lon = planets.get("Sun", {}).get("lon", 0)
base_planets = base_result.get("planets", {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
"degree": pdata.get("lon", pdata.get("degree", 0)),
"shadbala_total": base_planets.get(planet, {}).get("total_virupas", 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
"source": "scripts/shadbala_advanced.py",
"method": "Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层",
"kala_vmdh": kala_additions,
"top_kala_support": [{"planet": planet, "virupas": value} for planet, value in top_kala],
"yuddha_bala": yuddha,
"active_yuddha": active_yuddha,
"sputa_drik_bala": drik_sputa,
"next_action": "若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。",
}
except Exception as exc:
logger.warning("[api_server] shadbala advanced layer failed: %s", exc)
return {}
def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
import shadbala
planets, planet_lons, asc_sign_idx = handler._normalized_planets_from_body(body)
if not {"Sun", "Moon"} <= set(planet_lons):
raise ShadbalaInputError("planets must include Sun and Moon longitude data")
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
birth_hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
context = birth_context(handler, body, birth_hour, birth_minute, birth_second)
result = shadbala.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour_decimal,
planet_lons["Sun"],
planet_lons["Moon"],
birth_minute,
context=context,
)
advanced = advanced_layer(handler, body, planets, result)
return {
"success": True,
"endpoint": "shadbala",
"rule_variants": rule_variants(bool(advanced)),
"advanced_layer": advanced,
"result": result,
}
+102 -5
View File
@@ -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:
+3 -1
View File
@@ -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__,
+16 -3
View File
@@ -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]:
+5 -1
View File
@@ -38,7 +38,11 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217
# 原值:217
# 新值:215
# 原因:BUG-1463 把 /api/shadbala 移到 scripts/shadbala_service/api.py,
# 删除 _shadbala_rule_variants 与 _compute_shadbala_advanced_layer,_compute_shadbala 只转发
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 215
# 原值:31
# 新值:5
+151
View File
@@ -0,0 +1,151 @@
"""BUG-1463: `/api/shadbala` with the birth moment returned 500 (`NameError: swe`).
The endpoint now lives in scripts/shadbala_service/api.py and the handler only
forwards the body. These tests go through the real HTTP handler on a local
port, so the regression is caught at the same surface the caller saw.
Birth data is the fictional golden profile shared with the frontend tests
(1990-06-15 12:00 Beijing, raman).
"""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
import swisseph as swe
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
for path in (ROOT, SCRIPTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate # noqa: E402
from scripts.shadbala import build_shadbala_context, calc_dig_bala_precise # noqa: E402
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
PLANETS = GOLDEN["chart"]["planets"]
ASCENDANT = GOLDEN["chart"]["ascendant"]
BIRTH = {
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
}
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
@pytest.fixture(scope="module")
def api_base():
import os
from scripts.jyotish_api_server import DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, JyotishAPIHandler
previous = os.environ.get("JYOTISH_API_RATE_LIMIT_PER_MINUTE")
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = "0"
reset_heavy_compute_gate()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
server = ThreadingHTTPServer(("127.0.0.1", port), JyotishAPIHandler)
server.daemon_threads = True
base = f"http://127.0.0.1:{port}"
server.allowed_origins = set(DEFAULT_ALLOWED_ORIGINS) | {base}
server.allowed_hosts = set(DEFAULT_ALLOWED_HOSTS)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield base
finally:
server.shutdown()
server.server_close()
reset_heavy_compute_gate()
if previous is None:
os.environ.pop("JYOTISH_API_RATE_LIMIT_PER_MINUTE", None)
else:
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = previous
def _post(base: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
request = urllib.request.Request(
f"{base}/api/shadbala",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", "Origin": base, "Host": base.split("://", 1)[1]},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8") or "{}")
def test_birth_data_request_returns_precise_shadbala(api_base) -> None:
status, payload = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert status == 200, payload
assert payload["success"] is True
result = payload["result"]
# The birth moment switches Kala and Chesta to the Swiss Ephemeris context.
assert result["method_variants"] == {
"kala": "bphs_local_solar_events_ahargana_declination",
"chesta": "bphs_bounded_surya_mean_motion_seeghrochcha",
}
assert sorted(result["ranking"]) == sorted(SEVEN)
for planet in SEVEN:
row = result["planets"][planet]
assert row["total_rupas"] > 0
assert row["total_virupas"] == pytest.approx(row["total_rupas"] * 60, abs=0.05)
# Dig Bala is read from the Placidus bhava midpoints of the same instant (UT = 12:00 - 8h).
context = build_shadbala_context(swe.julday(1990, 6, 15, 4.0), 39.9042, 116.4074, "raman", 8.0)
for planet in SEVEN:
expected = calc_dig_bala_precise(planet, float(PLANETS[planet]["lon"]), context["house_midpoints"])
assert result["planets"][planet]["dig_bala"] == expected
assert payload["rule_variants"]["selected"] == ["core_sixfold", "advanced_evidence"]
assert payload["advanced_layer"]["source"] == "scripts/shadbala_advanced.py"
assert set(payload["advanced_layer"]["kala_vmdh"]) == set(SEVEN)
def test_birth_data_changes_the_strength_versus_planets_only(api_base) -> None:
status, payload = _post(api_base, {"planets": PLANETS, "ascendant": ASCENDANT, "hour": 12, "minute": 0})
assert status == 200, payload
legacy = payload["result"]
assert legacy["method_variants"]["kala"] == "legacy_approximation"
# Without the birth moment the answer is the chart page's `modules.shadbala` golden.
golden = GOLDEN["chart_modules_shadbala"]["planets"]
for planet in SEVEN:
assert legacy["planets"][planet]["total_rupas"] == pytest.approx(golden[planet]["total_rupas"], abs=1e-4)
_, precise = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert any(
precise["result"]["planets"][planet]["dig_bala"] != legacy["planets"][planet]["dig_bala"]
for planet in SEVEN
)
def test_bad_inputs_are_400_not_500(api_base) -> None:
status, _ = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT, "ayanamsa": "not-an-ayanamsa"})
assert status == 400
status, _ = _post(api_base, {**BIRTH, "planets": {"Mars": PLANETS["Mars"]}, "ascendant": ASCENDANT})
assert status == 400
def test_shadbala_service_and_api_server_have_no_undefined_names() -> None:
"""BUG-1463 was a module name used where it was never imported; ruff F821 sees that statically."""
ruff = pytest.importorskip("ruff")
targets = ["scripts/jyotish_api_server.py", "scripts/offline_compute_mixins.py", "scripts/shadbala_service"]
completed = subprocess.run(
[ruff.find_ruff_bin(), "check", "--isolated", "--no-cache", "--select", "F821", *targets],
cwd=ROOT, capture_output=True, text=True, check=False,
)
assert completed.returncode == 0, completed.stdout + completed.stderr
@@ -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"