diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cf9784ee..f3baef96 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4506,3 +4506,19 @@ - 相关记录:BUG-279、BUG-287、BUG-298 - 复发自:BUG-279(算了却不交给模型) - 修复版本:待提交 + +## BUG-301 | 网页咨询前台把 VedAstro 整段跳过,审计只能诚实 blocked + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:普通咨询 `POST /api/consult`、`execute_consultation_workflow` foreground、`vedastro_gateway`、模型包 `vedastro_cross_check` +- 用户现象:技法表里 VedAstro 云状态一直是阻塞,即使官方网关和网络可用。 +- 触发条件:聊天前台 `foreground: true` → `defer_optional_external_evidence: true`。 +- 根因:BUG-161 为避免 overview/full snapshot/range scan/gateway 串行超时,前台直接跳过 gateway 并写死 `foreground_optional_evidence_deferred`。本地层恢复后(BUG-300)仍把可选官方交叉验证当成“本轮不算”。 +- 修复:前台仍跳过 VedAstro 主入口 overview 与 range scan。gateway 与本地计算并行,子进程预算约 8 秒,本地完成后最多再等 1.5 秒。赶上且有 official raw 则合并为 `official_verified` 并投影日月升一星座;超时或失败保持 `official_blocked`,本地回答不失败。MEVG / PyJHora / jyotishganit 仍 blocked。不提高 `AGENT_TIMEOUT_MS`。 +- 验证:`tests/test_api_server_security.py` 前台赶上合并与超时降级;`tests/test_consultation_consumer_context.py` 压缩包去掉经纬度;`frontend/tests/consultation-context.test.ts`、`consultation-spectrum-parity.test.ts`。 +- 防复发:前台不得再同步串联 overview+snapshot+range scan;赶不上不得把未交付官方层标成 executed;压缩交叉验证不得带出生钟点或坐标。 +- 相关记录:BUG-161、BUG-159、BUG-300 +- 复发自:BUG-161(超时防护把可选官方层整段关掉) +- 修复版本:待提交 diff --git a/frontend/src/lib/consultation-technique-audit.ts b/frontend/src/lib/consultation-technique-audit.ts index 8ab312da..07115ea7 100644 --- a/frontend/src/lib/consultation-technique-audit.ts +++ b/frontend/src/lib/consultation-technique-audit.ts @@ -67,6 +67,8 @@ const NOTE_LABELS: Readonly> = { official_raw_archive_not_auditable_for_this_run: "官方原始档案本轮不可审计", official_raw_archive_is_auditable: "官方原始档案可审计", official_cloud_evidence_available: "官方云证据可用", + foreground_optional_evidence_timeout: "前台官方云证据超时,本轮未赶上", + gateway_invocation_error: "官方云网关调用失败", claims_capped_until_pyjhora_jhora_jyotishganit_are_invoked_for_this_run: "结论上限受外部引擎调用限制", three_engine_runtime_closure_available: "三引擎运行时已闭环", complete_packet_required_for_high_confidence: "高置信度需完整证据包", diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 0f427fca..f8c7f3e7 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -270,6 +270,7 @@ const validationKeys = new Set([ "confidencecap", "summary", "conclusion", "publiccontextonly", "similarpubliccases", "highsimilaritypublicreferencesavailable", "requesteduncovereddomains", "timingstate", "partialmatch", "narayanastatus", "transitstatus", "productiontuningallowed", "nomajorityvote", + "vedastrocrosscheck", "officialclosurestate", "officialclosurereason", "natal", "ascendant", ]); const domainDetailsKeys = new Set([ @@ -465,6 +466,7 @@ function projectValidationEvidence(context: ReturnType> = { @@ -648,6 +651,7 @@ export function toAgentConsultationContext(data: JsonRecord) { range_boundary_contexts: record(data.range_boundary_contexts), thematic_evidence: selectedTheme, vedastro_gateway: record(data.vedastro_gateway), + vedastro_cross_check: record(consumerContext.vedastro_cross_check), external_engine_evidence: { runtime_truth: record(data.runtime_truth), numerical_parity: record(data.external_parity_gate), real_case_calibration: record(data.real_case_calibration), diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index 986a9d79..9543766e 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -157,6 +157,7 @@ test("projects only bounded server-selected evidence to the model", () => { { technique: "Narayana Dasha", status: "executed", system: "jyotish" }, { technique: "Functional Benefic/Malefic", status: "executed", key_functional_benefics: ["Jupiter"] }, { technique: "Western natal (tropical)", status: "executed", system: "western" }, + { technique: "VedAstro Cloud State", status: "executed", boundary: "official_cloud_evidence_available" }, { technique: "MEVG / Global Web Evidence", status: "blocked", boundary: "caps_claims_until_global_web_evidence_runs" }, ], varga_spectrum: { @@ -169,9 +170,18 @@ test("projects only bounded server-selected evidence to the model", () => { natal: { sun: "Aries", moon: "Taurus", ascendant: "Leo" }, techniques: { transits: { status: "executed" } }, }, + vedastro_cross_check: { + status: "executed", + official_closure_state: "official_verified", + natal: { sun: "Leo", moon: "Cancer", ascendant: "Scorpio" }, + }, }, reference_transparency: { similar_public_cases: { status: "public_context_only" } }, rectification: { summary: "none" }, + vedastro_gateway: { + official_closure_state: "official_verified", + official_raw_response: { lat: 25.03, lon: 121.56, hour: 3, minute: 4 }, + }, }); const output = toModelOutput(context); const careerPlan = createConsultationPlan({ userIntent: "事业如何", theme: "career" }); @@ -231,6 +241,9 @@ test("projects only bounded server-selected evidence to the model", () => { assert.equal(Array.isArray(mustUse), true); assert.equal((mustUse as string[]).includes("Yogas"), true); assert.equal((mustUse as string[]).includes("Arudha / UL / A10"), true); + assert.equal((mustUse as string[]).includes("VedAstro Cloud State"), true); assert.equal((mustUse as string[]).includes("MEVG / Global Web Evidence"), false); + assert.equal(serialized.includes("Scorpio"), true); + assert.equal(serialized.includes("official_verified"), true); assert.equal(serialized.includes("https://"), false); }); diff --git a/frontend/tests/consultation-spectrum-parity.test.ts b/frontend/tests/consultation-spectrum-parity.test.ts index 883acd95..94c9f31c 100644 --- a/frontend/tests/consultation-spectrum-parity.test.ts +++ b/frontend/tests/consultation-spectrum-parity.test.ts @@ -21,6 +21,8 @@ test("web consult compute and projection keep the live skill's locally computed } assert.match(attach, /_compact_yoga_entry/); assert.match(attach, /_compact_chara_dasha/); + assert.match(attach, /_join_foreground_vedastro/); + assert.match(attach, /skip_vedastro_main_entry_overview': defer_optional_external_evidence/); assert.doesNotMatch(attach, /defer_optional_external_evidence = False/); assert.match(workflow, /yogas: context\.local_layers\.yogas/); diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 2e485a81..6734b52e 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -22,7 +22,7 @@ import threading import time import tempfile import uuid -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError from datetime import datetime, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -105,6 +105,11 @@ _ASYNC_JOB_EXECUTOR = ThreadPoolExecutor( max_workers=_ASYNC_JOB_WORKERS, thread_name_prefix='jyotish-job', ) +_FOREGROUND_VEDASTRO_WORKERS = max(int(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_WORKERS', '2')), 1) +_FOREGROUND_VEDASTRO_EXECUTOR = ThreadPoolExecutor( + max_workers=_FOREGROUND_VEDASTRO_WORKERS, + thread_name_prefix='jyotish-vedastro-fg', +) _ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE) _RATE_LIMIT_LOCK = threading.Lock() _RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {} @@ -1586,6 +1591,123 @@ def _build_consumer_context( } +_VEDASTRO_NATAL_SIGN_KEYS = ('sun', 'moon', 'ascendant') +_VEDASTRO_COMPACT_DENY_KEYS = { + 'lat', 'lon', 'lng', 'latitude', 'longitude', 'hour', 'minute', 'second', + 'date', 'time', 'birth', 'tz', 'timezone', 'city', 'address', 'reported_time', +} + + +def _foreground_vedastro_budget_seconds() -> float: + raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS', '8')).strip() + try: + return min(max(float(raw), 2.0), 12.0) + except ValueError: + return 8.0 + + +def _foreground_vedastro_join_seconds() -> float: + raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS', '1.5')).strip() + try: + return min(max(float(raw), 0.0), 3.0) + except ValueError: + return 1.5 + + +def _vedastro_natal_signs(raw) -> dict | None: + if not isinstance(raw, dict): + return None + natal = raw.get('natal') if isinstance(raw.get('natal'), dict) else raw + if not isinstance(natal, dict): + return None + compact = {} + sign_names = set(SIGNS) + for key in _VEDASTRO_NATAL_SIGN_KEYS: + value = natal.get(key) or natal.get(key.title()) or natal.get(key.capitalize()) + sign = None + if isinstance(value, str) and value in sign_names: + sign = value + elif isinstance(value, dict): + candidate = value.get('sign') or value.get('rasi') + if isinstance(candidate, str) and candidate in sign_names: + sign = candidate + if sign: + compact[key] = sign + return compact or None + + +def _compact_vedastro_cross_check(vedastro_official: dict, vedastro_gateway: dict | None = None) -> dict: + official = vedastro_official if isinstance(vedastro_official, dict) else {} + gateway = vedastro_gateway if isinstance(vedastro_gateway, dict) else {} + state = str( + official.get('official_closure_state') + or gateway.get('official_closure_state') + or official.get('status') + or gateway.get('status') + or 'official_blocked' + ) + reason = official.get('official_closure_reason') or gateway.get('official_closure_reason') + compact = { + 'status': 'executed' if state == 'official_verified' else 'blocked', + 'official_closure_state': state, + } + if reason: + compact['official_closure_reason'] = str(reason) + raw = ( + official.get('raw_response') + or official.get('official_raw_response') + or gateway.get('official_raw_response') + or gateway.get('raw_response') + ) + natal = _vedastro_natal_signs(raw) + if natal: + compact['natal'] = natal + for denied in _VEDASTRO_COMPACT_DENY_KEYS: + compact.pop(denied, None) + if isinstance(compact.get('natal'), dict): + compact['natal'].pop(denied, None) + return compact + + +def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict: + packet = { + 'scope': 'vedastro_gateway_run', + 'status': 'official_blocked', + 'official_closure_state': 'official_blocked', + 'official_closure_reason': reason, + } + if error_type: + packet['error_type'] = error_type + return packet + + +def _run_foreground_vedastro_gateway(handler, body: dict) -> dict: + try: + with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()): + result = handler._compute_vedastro_gateway_run(body) + except Exception as exc: + return _blocked_foreground_vedastro( + reason='gateway_invocation_error', + error_type=type(exc).__name__, + ) + return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error') + + +def _join_foreground_vedastro(future, *, timeout: float) -> dict: + if future is None: + return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout') + try: + result = future.result(timeout=timeout) + except FuturesTimeoutError: + return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout') + except Exception as exc: + return _blocked_foreground_vedastro( + reason='gateway_invocation_error', + error_type=type(exc).__name__, + ) + return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error') + + def execute_consultation_workflow( handler, *, @@ -1709,6 +1831,13 @@ def execute_consultation_workflow( rectification = {} muhurta_panchanga = {} computed_chart = bool(chart) + foreground_vedastro_future = None + if defer_optional_external_evidence: + # Overlap a bounded official gateway with local compute. Main-entry + # overview / range scan stay skipped so foreground cannot replay BUG-161. + foreground_vedastro_future = _FOREGROUND_VEDASTRO_EXECUTOR.submit( + _run_foreground_vedastro_gateway, handler, dict(body), + ) for step in runtime_planner.get('sync_steps', []): if step == 'run_prashna': @@ -1790,12 +1919,10 @@ def execute_consultation_workflow( vedastro_gateway = rectification.get('vedastro_gateway') if isinstance(rectification, dict) else None 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', - } + vedastro_gateway = _join_foreground_vedastro( + foreground_vedastro_future, + timeout=_foreground_vedastro_join_seconds(), + ) elif not isinstance(vedastro_gateway, dict): try: vedastro_gateway = handler._compute_vedastro_gateway_run(body) @@ -1884,6 +2011,14 @@ def execute_consultation_workflow( quality_gate=runtime_evidence_log.get('quality_gate') if isinstance(runtime_evidence_log.get('quality_gate'), dict) else {}, entry_mode=str(entry_mode or 'direct_chart'), ) + consumer_context['vedastro_cross_check'] = _compact_vedastro_cross_check( + vedastro_official, + vedastro_gateway if isinstance(vedastro_gateway, dict) else {}, + ) + consumer_context['vedastro_cross_check'] = _compact_vedastro_cross_check( + vedastro_official, + vedastro_gateway if isinstance(vedastro_gateway, dict) else {}, + ) result = { 'success': True, diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index bddfbe01..b8a7916e 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -2395,7 +2395,7 @@ 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: +def test_consultation_foreground_skips_vedastro_main_entry_overview(monkeypatch) -> None: handler = _handler() seen = {} fake_chart = { @@ -2432,7 +2432,17 @@ def test_consultation_foreground_defers_optional_vedastro_calls(monkeypatch) -> monkeypatch.setattr( handler, '_compute_vedastro_gateway_run', - lambda body: pytest.fail('foreground consultation must not call VedAstro gateway'), + lambda body: { + 'scope': 'vedastro_gateway_run', + 'status': 'official_verified', + 'official_closure_state': 'official_verified', + 'official_raw_response': { + 'request_id': 'fg-ok', + 'natal': {'sun': 'Leo', 'moon': 'Taurus', 'ascendant': 'Cancer'}, + 'lat': 25.03, + 'lon': 121.56, + }, + }, ) result = handler._compute_consultation_workflow({ @@ -2452,10 +2462,95 @@ def test_consultation_foreground_defers_optional_vedastro_calls(monkeypatch) -> 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['vedastro_gateway']['official_closure_state'] == 'official_verified' + assert result['vedastro_official']['official_closure_state'] == 'official_verified' + assert result['consumer_context']['vedastro_cross_check']['status'] == 'executed' + assert result['consumer_context']['vedastro_cross_check']['natal'] == { + 'sun': 'Leo', + 'moon': 'Taurus', + 'ascendant': 'Cancer', + } + assert 'lat' not in result['consumer_context']['vedastro_cross_check'] + assert 'lon' not in result['consumer_context']['vedastro_cross_check'] assert result['consumer_context']['answer_policy']['can_answer_direction'] is True assert result['consumer_context']['answer_policy']['provider_unavailable_is_fatal'] is False + audit = { + row['technique']: row['status'] + for row in result['consumer_context']['technique_audit_table'] + if isinstance(row, dict) + } + assert audit.get('VedAstro Cloud State') == 'executed' + assert audit.get('MEVG / Global Web Evidence') == 'blocked' + + +def test_consultation_foreground_times_out_vedastro_without_failing_local(monkeypatch) -> None: + handler = _handler() + 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'}, + } + + monkeypatch.setenv('JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS', '0.05') + monkeypatch.setenv('JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS', '2') + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'summary': {'recommended_events': [], 'warned': [], 'disabled': []}, + }) + monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: { + 'success': True, + 'endpoint': 'thematic_report', + 'themes': {}, + }) + + def slow_gateway(body): + time.sleep(0.4) + return { + 'scope': 'vedastro_gateway_run', + 'status': 'official_verified', + 'official_closure_state': 'official_verified', + 'official_raw_response': {'request_id': 'too-late'}, + } + + monkeypatch.setattr(handler, '_compute_vedastro_gateway_run', slow_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 result['success'] is True + assert result['vedastro_gateway']['official_closure_reason'] == 'foreground_optional_evidence_timeout' + assert result['consumer_context']['vedastro_cross_check']['status'] == 'blocked' + assert result['consumer_context']['answer_policy']['can_answer_direction'] is True + audit = { + row['technique']: row['status'] + for row in result['consumer_context']['technique_audit_table'] + if isinstance(row, dict) + } + assert audit.get('VedAstro Cloud State') == 'blocked' + assert audit.get('MEVG / Global Web Evidence') == 'blocked' def test_consultation_workflow_accepts_western_oracle_payload(monkeypatch) -> None: diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index b5394036..188ed7df 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -20,6 +20,7 @@ from jyotish_api_server import ( # noqa: E402 JyotishAPIHandler, _attach_local_consultation_layers, _build_consumer_context, + _compact_vedastro_cross_check, ) from unified_consultation_orchestrator import UnifiedConsultationOrchestrator # noqa: E402 @@ -700,3 +701,35 @@ def test_failed_vedastro_raw_packet_is_not_marked_as_used() -> None: ) assert packet['sections']['vedastro_official_raw_response']['status'] == 'received_unverified' + + +def test_compact_vedastro_cross_check_keeps_signs_and_drops_coordinates() -> None: + compact = _compact_vedastro_cross_check( + {'official_closure_state': 'official_verified', 'status': 'ok'}, + { + 'official_raw_response': { + 'natal': {'sun': 'Leo', 'moon': 'Taurus', 'ascendant': 'Cancer'}, + 'lat': 25.03, + 'lon': 121.56, + 'hour': 3, + 'minute': 4, + }, + }, + ) + assert compact['status'] == 'executed' + assert compact['natal'] == {'sun': 'Leo', 'moon': 'Taurus', 'ascendant': 'Cancer'} + assert 'lat' not in compact + assert 'lon' not in compact + assert 'hour' not in compact + + +def test_compact_vedastro_cross_check_marks_timeout_blocked() -> None: + compact = _compact_vedastro_cross_check( + {}, + { + 'official_closure_state': 'official_blocked', + 'official_closure_reason': 'foreground_optional_evidence_timeout', + }, + ) + assert compact['status'] == 'blocked' + assert compact['official_closure_reason'] == 'foreground_optional_evidence_timeout' diff --git a/tests/test_consultation_workflow_domains.py b/tests/test_consultation_workflow_domains.py index 12cbccf5..5e6562d2 100644 --- a/tests/test_consultation_workflow_domains.py +++ b/tests/test_consultation_workflow_domains.py @@ -53,6 +53,16 @@ def _stub_consultation_runtime(monkeypatch, handler: JyotishAPIHandler) -> None: "_compute_muhurta_panchanga", lambda body: {"status": "ok", "scope": "muhurta_panchanga"}, ) + monkeypatch.setattr( + handler, + "_compute_vedastro_gateway_run", + lambda body: { + "scope": "vedastro_gateway_run", + "status": "official_blocked", + "official_closure_state": "official_blocked", + "official_closure_reason": "test_stub", + }, + ) @pytest.mark.parametrize("domain", CANONICAL_DOMAINS)