merge: consultation fixes into staging
Staging Backend Quality Gate / validate (push) Successful in 12m19s
Staging Backend Quality Gate / publish (push) Successful in 8m44s

# Conflicts:
#	docs/BUG_HISTORY.md
This commit is contained in:
Jesse_Chen
2026-08-11 12:00:25 +08:00
9 changed files with 191 additions and 36 deletions
+16
View File
@@ -2728,3 +2728,19 @@
- 相关记录:BUG-010、BUG-090、BUG-123
- 复发自:无
- 修复版本:本地 staging 候选(未 push / deploy
## BUG-161 | 咨询状态读取 503 与前台排盘在模型调用前超时
- 状态:resolvedlocal candidate,待迁移与部署验收)
- 首次发现:2026-08-11
- 最近更新:2026-08-11
- 影响面:`GET /api/consult/status`、首页前台咨询生成;个人报告与高严谨工作流继续保留完整外部证据链。
- 用户现象:咨询状态接口返回“暂时无法读取咨询状态”;已发布且启用的模型仍无法开始聊天,咨询请求最终取消且没有生成回复。
- 触发条件:self-hosted Web 以 `service_role` 直查启用 RLS 的 `consultation_requests`;前台咨询缓存未命中新星盘时,同步执行 VedAstro overview/full snapshot/range scan 与末尾 gateway,累计耗时超过 Web 请求时限。
- 根因:这是两个独立故障。状态接口所用 `service_role` 缺少 `consultation_requests` 的表级 SELECT;聊天失败发生在模型调用前,排盘主链把可选外部交叉验证当作前台同步必经步骤,多个外部调用超时累计后触发前端 workflow 超时。
- 修复:向前迁移仅授予 `service_role``consultation_requests` 的 SELECT,浏览器角色仍无表权限;只有 `/api/consult` 传入 foreground 模式,Python 前台路径跳过 VedAstro main-entry overview 与 gateway,返回明确的 `foreground_optional_evidence_deferred` 降级状态并继续使用本地 D1、分盘、Arudha、Narayana、Ashtakavarga 与 KP 证据。个人报告保持默认完整工作流,排盘缓存键区分是否跳过外部 overview,避免快速结果污染完整报告缓存。
- 验证:Python 聚焦回归覆盖 foreground 标志传播、overview/gateway 不执行、本地结果可回答且外部供应商不可用不致命;前端合同覆盖只有聊天传 foreground、报告不传;数据库合同覆盖 `service_role` 有 SELECT 且 `anon`/`authenticated` 无 SELECT。最终本地测试结果见本次任务记录。
- 防复发:服务器直查启用 RLS 的表必须同时验证运行角色、表权限与策略;用户前台请求不得同步串联多个可选外部证据调用;快速排盘与完整证据排盘必须使用不同缓存键。
- 相关记录:BUG-159、ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104
- 复发自:无
- 修复版本:本地 staging 候选(未 push / deploy
+1 -1
View File
@@ -480,7 +480,7 @@ export async function POST(request: Request) {
theme: parsed.data.theme,
});
const workflowContext = applyBirthTimeModeToWorkflowContext(
await runConsultationWorkflow(toolInput),
await runConsultationWorkflow(toolInput, { foreground: true }),
consultationMode,
);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
+5 -1
View File
@@ -55,7 +55,10 @@ const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
export async function runConsultationWorkflow(input: ConsultationInput) {
export async function runConsultationWorkflow(
input: ConsultationInput,
options?: { foreground?: boolean },
) {
const { entryMode, question, theme, ...workflowInput } = input;
const workflowRequest = projectConsultationWorkflowRequest(question, theme);
const response = await fetch(`${apiBase}/api/consultation_workflow`, {
@@ -67,6 +70,7 @@ export async function runConsultationWorkflow(input: ConsultationInput) {
question: workflowRequest.question,
question_text: workflowRequest.question,
theme: workflowRequest.themes,
defer_optional_external_evidence: options?.foreground === true,
}),
signal: AbortSignal.timeout(90_000),
});
@@ -0,0 +1,5 @@
begin;
grant select on table public.consultation_requests to service_role;
commit;
@@ -6,6 +6,7 @@ const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.ur
const consultRoute = read("src/app/api/consult/route.ts");
const statusRoute = read("src/app/api/consult/status/route.ts");
const migration = read("supabase/migrations/20260808030000_consultation_stream_recovery.sql");
const statusReadMigration = read("supabase/migrations/20260811010000_consultation_status_service_role_read.sql");
test("reserves usage and binds the owned consultation session atomically", () => {
assert.match(migration, /add column if not exists session_id uuid references public\.chat_sessions\(id\) on delete set null/i);
@@ -75,6 +76,11 @@ test("status endpoint supports one global reserved lookup and strict bound polli
assert.doesNotMatch(statusRoute, /String\(data\.response_message\)|responseMessage:\s*data\.response_message as string/);
});
test("status polling grants consultation request reads only to the server role", () => {
assert.match(statusReadMigration, /grant select on table public\.consultation_requests to service_role/i);
assert.doesNotMatch(statusReadMigration, /grant select[\s\S]*to (anon|authenticated)/i);
});
test("detached completion and cancellation use a bounded retry ceiling", () => {
assert.match(consultRoute, /const detachedSettlementAttempts = 3/);
assert.match(consultRoute, /ponytail: Staging MVP ceiling—without a queue\/worker/);
@@ -3,20 +3,27 @@ import { readFileSync } from "node:fs";
import test from "node:test";
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
test("runs the Jyotish workflow before streaming a commercial consultation", () => {
const chartBranch = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse"));
assert.match(route, /runConsultationWorkflow/);
assert.match(chartBranch, /await runConsultationWorkflow\(toolInput\)/);
assert.match(chartBranch, /await runConsultationWorkflow\(toolInput, \{ foreground: true \}\)/);
assert.match(chartBranch, /getJyotishAgent\(selectedModel, workflowContext\)\.stream/);
assert.ok(
chartBranch.indexOf("await runConsultationWorkflow(toolInput)")
chartBranch.indexOf("await runConsultationWorkflow(toolInput, { foreground: true })")
< chartBranch.indexOf("getJyotishAgent(selectedModel, workflowContext).stream"),
);
});
test("defers optional external evidence only for foreground chat", () => {
assert.match(mastra, /defer_optional_external_evidence: options\?\.foreground === true/);
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/);
assert.doesNotMatch(reportsRoute, /foreground:\s*true/);
});
test("grounds the answer in the server-computed workflow without a second tool run", () => {
assert.match(mastra, /function getJyotishAgent\(model: ResolvedLanguageModel, workflowContext\?/);
assert.match(mastra, /workflowContext \? \{\} : \{ consultationTool \}/);
@@ -47,6 +47,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260806030000_settle_order_usage_authorization\.sql/);
assert.match(migration.stdout, /applied 20260806040000_model_configuration\.sql/);
assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/);
assert.match(migration.stdout, /applied 20260811010000_consultation_status_service_role_read\.sql/);
assert.equal(
fixture.psql(`
@@ -84,6 +85,15 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
`),
"true:f",
);
assert.equal(
fixture.psql(`
select
has_table_privilege('service_role', 'public.consultation_requests', 'select') || ':' ||
has_table_privilege('anon', 'public.consultation_requests', 'select') || ':' ||
has_table_privilege('authenticated', 'public.consultation_requests', 'select')
`),
"true:f:f",
);
assert.equal(
fixture.psql(`
+52 -32
View File
@@ -839,6 +839,7 @@ 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'))
try:
from scripts.three_engine_parity_replay_validator import validate_manifest
except ModuleNotFoundError: # pragma: no cover - direct script execution
@@ -949,7 +950,10 @@ def execute_consultation_workflow(
executed_steps.append('run_rectification_gate')
elif step == 'compute_chart':
if not computed_chart:
chart = handler._compute_chart(birth_payload)
chart = handler._compute_chart({
**birth_payload,
'skip_vedastro_main_entry_overview': defer_optional_external_evidence,
})
computed_chart = True
executed_steps.append('compute_chart')
@@ -992,7 +996,14 @@ def execute_consultation_workflow(
executed_steps.append('run_thematic_report')
vedastro_gateway = rectification.get('vedastro_gateway') if isinstance(rectification, dict) else None
if not isinstance(vedastro_gateway, dict):
if defer_optional_external_evidence:
vedastro_gateway = {
'scope': 'vedastro_gateway_run',
'status': 'local_fallback',
'official_closure_state': 'official_blocked',
'official_closure_reason': 'foreground_optional_evidence_deferred',
}
elif not isinstance(vedastro_gateway, dict):
try:
vedastro_gateway = handler._compute_vedastro_gateway_run(body)
except Exception as exc: # Gateway evidence must not block the local chart result.
@@ -1232,6 +1243,7 @@ def _build_api_chart_cache_payload(body: dict) -> dict:
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
'skip_vedastro_main_entry_overview': bool(body.get('skip_vedastro_main_entry_overview')),
},
'vedastro_runtime': _vedastro_runtime_fingerprint(),
}
@@ -5457,29 +5469,36 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'dasha': result['dasha'],
'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {},
}
_attach_vedastro_main_entry_overview(result, {
'year': year,
'month': month,
'day': day,
'hour': int(hour),
'minute': int(minute),
'second': int(second),
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': ayanamsa_name,
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
})
if not body.get('skip_vedastro_main_entry_overview'):
_attach_vedastro_main_entry_overview(result, {
'year': year,
'month': month,
'day': day,
'hour': int(hour),
'minute': int(minute),
'second': int(second),
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': ayanamsa_name,
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
})
_attach_guided_topics(result)
result['ai_prompt_pack'] = self._build_chart_prompt_pack(result)
return _store_api_chart_response_cache(cache_payload, result)
except ImportError:
fallback = self._fallback_chart(year, month, day, hour, minute, second, lat, lon, tz)
fallback = self._fallback_chart(
year, month, day, hour, minute, second, lat, lon, tz,
skip_vedastro_main_entry_overview=bool(body.get('skip_vedastro_main_entry_overview')),
)
return _store_api_chart_response_cache(cache_payload, fallback)
def _fallback_chart(self, year, month, day, hour, minute, second, lat, lon, tz):
def _fallback_chart(
self, year, month, day, hour, minute, second, lat, lon, tz,
*, skip_vedastro_main_entry_overview=False,
):
"""无Swiss Ephemeris时的简化计算"""
import hashlib
seed = int(hashlib.md5(f"{year}{month}{day}{hour}{minute}{second}{lat}{lon}".encode()).hexdigest()[:8], 16)
@@ -5544,19 +5563,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'dasha': result['dasha'],
'shadbala': {},
}
_attach_vedastro_main_entry_overview(result, {
'year': year,
'month': month,
'day': day,
'hour': int(hour),
'minute': int(minute),
'second': int(second),
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': 'lahiri',
'node_mode': 'mean',
})
if not skip_vedastro_main_entry_overview:
_attach_vedastro_main_entry_overview(result, {
'year': year,
'month': month,
'day': day,
'hour': int(hour),
'minute': int(minute),
'second': int(second),
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': 'lahiri',
'node_mode': 'mean',
})
_attach_guided_topics(result)
result['ai_prompt_pack'] = self._build_chart_prompt_pack(result)
return result
+87
View File
@@ -2384,6 +2384,69 @@ def test_consultation_workflow_uses_unified_orchestrator_contract(monkeypatch) -
assert result['reference_transparency']['similar_public_cases']['does_not_predict_user_outcome'] is True
def test_consultation_foreground_defers_optional_vedastro_calls(monkeypatch) -> None:
handler = _handler()
seen = {}
fake_chart = {
'success': True,
'birth_info': {'date': '1997-08-08', 'time': '05:00', 'tz': 8},
'ascendant': {'lon': 92.0, 'sign': 'Cancer', 'sign_idx': 3},
'planets': _sample_planets(),
'dasha': {'periods': [{'lord': 'Sun', 'start': '2026-01-01', 'end': '2027-01-01'}]},
'modules': {
'varga_full': {'D9': {}, 'D10': {}},
'arudha_padas': {'A10': {}, 'UL': {}},
'narayana_dasha': {'periods': []},
'ashtakavarga': {'sav': []},
'kp_cusps': {'houses': []},
},
'special_lagnas': {'precision': 'sunrise_correct'},
}
def fake_chart_compute(body):
seen['chart_body'] = dict(body)
return fake_chart
monkeypatch.setattr(handler, '_compute_chart', fake_chart_compute)
monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: {
'success': True,
'summary': {'recommended_events': [], 'warned': [], 'disabled': []},
})
monkeypatch.setattr(handler, '_compute_muhurta_panchanga', lambda body: {'status': 'ok'})
monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: {
'success': True,
'endpoint': 'thematic_report',
'themes': {},
})
monkeypatch.setattr(
handler,
'_compute_vedastro_gateway_run',
lambda body: pytest.fail('foreground consultation must not call VedAstro gateway'),
)
result = handler._compute_consultation_workflow({
'entry_mode': 'direct_chart',
'question': '应期与阶段问题:深入看今日',
'theme': ['career'],
'year': 1997,
'month': 8,
'day': 8,
'hour': 5,
'minute': 0,
'lat': 36.420487,
'lon': 114.209936,
'tz': 8,
'defer_optional_external_evidence': True,
})
assert seen['chart_body']['skip_vedastro_main_entry_overview'] is True
assert result['success'] is True
assert result['vedastro_gateway']['status'] == 'local_fallback'
assert result['vedastro_gateway']['official_closure_reason'] == 'foreground_optional_evidence_deferred'
assert result['consumer_context']['answer_policy']['can_answer_direction'] is True
assert result['consumer_context']['answer_policy']['provider_unavailable_is_fatal'] is False
def test_consultation_workflow_accepts_western_oracle_payload(monkeypatch) -> None:
handler = _handler()
fake_chart = {
@@ -3286,6 +3349,30 @@ def test_chart_auto_attaches_vedastro_main_entry_boundary(monkeypatch: pytest.Mo
assert vedastro["status"] == "network_execution_disabled"
def test_chart_foreground_mode_skips_vedastro_main_entry(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
monkeypatch.setattr(
jyotish_api_server,
"_attach_vedastro_main_entry_overview",
lambda *_args, **_kwargs: pytest.fail("foreground chart must not run VedAstro overview"),
)
result = _handler()._compute_chart({
'year': 1990,
'month': 6,
'day': 15,
'hour': 12,
'minute': 0,
'lat': 39.9,
'lon': 116.4,
'tz': 8,
'skip_vedastro_main_entry_overview': True,
})
assert result['success'] is True
assert 'vedastro_range_scan_result' not in result.get('modules', {})
def test_api_chart_response_cache_reuses_cached_value(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "600")
monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False)