fix: keep foreground consultations responsive
This commit is contained in:
@@ -2712,3 +2712,19 @@
|
||||
- 相关记录:ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user