diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d529b2cc..634e12fa 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2728,3 +2728,19 @@ - 相关记录:BUG-010、BUG-090、BUG-123 - 复发自:无 - 修复版本:本地 staging 候选(未 push / deploy) + +## BUG-161 | 咨询状态读取 503 与前台排盘在模型调用前超时 + +- 状态:resolved(local 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) diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index bc4a6707..170534dd 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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); diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index c9c6821a..a0168236 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -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), }); diff --git a/frontend/supabase/migrations/20260811010000_consultation_status_service_role_read.sql b/frontend/supabase/migrations/20260811010000_consultation_status_service_role_read.sql new file mode 100644 index 00000000..abb8cf1b --- /dev/null +++ b/frontend/supabase/migrations/20260811010000_consultation_status_service_role_read.sql @@ -0,0 +1,5 @@ +begin; + +grant select on table public.consultation_requests to service_role; + +commit; diff --git a/frontend/tests/consultation-stream-recovery.test.ts b/frontend/tests/consultation-stream-recovery.test.ts index f160f0d1..faf985d4 100644 --- a/frontend/tests/consultation-stream-recovery.test.ts +++ b/frontend/tests/consultation-stream-recovery.test.ts @@ -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/); diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 8d2efdd4..8d7d29f5 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -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 \}/); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 467c70d1..d0561774 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -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(` diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 72927a99..3be3cec9 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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 diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index e5a20977..9592c5eb 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -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)